From ccb1eae6134ff08148d218073d7a5d41b590571e Mon Sep 17 00:00:00 2001 From: skylartaylor Date: Tue, 13 Oct 2026 01:47:19 +0200 Subject: [PATCH 1/7] fix(print): refuse fitting that would discard annotations --- crates/print/README.md | 4 ++++ crates/print/src/sheets.rs | 9 ++++++++ crates/print/src/tests/windows_native.rs | 28 ++++++++++++++++++++++++ 3 files changed, 41 insertions(+) diff --git a/crates/print/README.md b/crates/print/README.md index 28fabea9..1277aae9 100644 --- a/crates/print/README.md +++ b/crates/print/README.md @@ -61,6 +61,10 @@ a driver paper-size mismatch. On Windows `command_preview` describes the in-proc (`xps-print`, then the printer and options as `key=value`) instead of a command line. The crate does not provide PostScript output, separations, trapping or colour-managed device rendering. +Windows shrink-to-fit refuses pages with annotations when their geometry needs fitting. +Callers must flatten annotation appearances into page content first. Exact-size printing and +rotation retain the annotations. This avoids losing printed comments, forms or other marks. + `cargo test -p craft-print` tests CUPS transport (including pipe-sized jobs and refusal), printer parsing, PPD options and argument mapping, and on every platform the Windows pieces that need no printer: the PrintTicket, the checks on the driver's validated ticket and capabilities, diff --git a/crates/print/src/sheets.rs b/crates/print/src/sheets.rs index 7b783980..7ba70e1a 100644 --- a/crates/print/src/sheets.rs +++ b/crates/print/src/sheets.rs @@ -1068,6 +1068,15 @@ impl Sheets<'_> { if get(&self.trailer, b"Encrypt").is_some() { return Err(refuse("it is encrypted")); } + // Annotation appearances live outside /Contents. Transforming only content + // would leave them at the old coordinates; deleting them loses printed marks. + // Require callers to flatten before fitting until appearances can be reframed. + if let Some(annotations) = get(&dict, b"Annots") + && !matches!(annotations, Obj::Null) + && !matches!(annotations, Obj::Array(items) if items.is_empty()) + { + return Err(refuse("flatten page annotations before using shrink-to-fit")); + } let [a, b, c, d, e, f] = frame.matrix; let [x, y, w, h] = frame.clip; let prefix = format!( diff --git a/crates/print/src/tests/windows_native.rs b/crates/print/src/tests/windows_native.rs index 197f8cec..6b226595 100644 --- a/crates/print/src/tests/windows_native.rs +++ b/crates/print/src/tests/windows_native.rs @@ -49,6 +49,10 @@ const TABLOID: TestPage = TestPage { media: Some([0.0, 0.0, 792.0, 1224.0]), cro /// cross-reference stream, as modern writers save. The root page node sets a Letter MediaBox /// for pages that inherit it. fn test_pdf(pages: &[TestPage], compressed: bool) -> Vec { + test_pdf_with_annotations(pages, compressed, None) +} + +fn test_pdf_with_annotations(pages: &[TestPage], compressed: bool, annotations: Option<&str>) -> Vec { let n = pages.len(); // 1 catalog, 2 page tree, 3 content, then the pages. let kids: Vec = (0..n).map(|i| format!("{} 0 R", 4 + i)).collect(); @@ -69,6 +73,9 @@ fn test_pdf(pages: &[TestPage], compressed: bool) -> Vec { if let Some(r) = p.rotate { page += &format!(" /Rotate {r}"); } + if let Some(annotations) = annotations { + page += &format!(" /Annots {annotations}"); + } objects.push(page + " >>"); } let mut pdf = b"%PDF-1.7\n%\xe2\xe3\xcf\xd3\n".to_vec(); @@ -264,6 +271,27 @@ fn shrink_to_fit_reframes_sheets_on_the_paper() { assert_eq!(shapes(&sheets.update(&edits).unwrap()), [((612, 792), 0), ((612, 792), 270), ((612, 792), 0)]); } +#[test] +fn shrink_to_fit_refuses_to_drop_annotation_appearances() { + use crate::sheets::{Fit, plan, read}; + for annotations in ["[<< /Subtype /Text /Rect [36 36 72 72] /F 4 >>]", "42 0 R"] { + let pdf = test_pdf_with_annotations(&[LETTER, LETTER_LANDSCAPE], false, Some(annotations)); + let sheets = read(&pdf).unwrap(); + let fit = plan(&sheets.sheets, (612.0, 792.0), Fit::Shrink { margin: 18.0 }).unwrap(); + assert!(matches!(sheets.update(&fit), Err(Error::Spool(ref m)) if m.contains("flatten page annotations"))); + let exact = plan(&sheets.sheets, (612.0, 792.0), Fit::Exact).unwrap(); + let rotated = sheets.update(&exact).unwrap(); + assert!(rotated.starts_with(&pdf)); + assert!(String::from_utf8_lossy(&rotated[pdf.len()..]).contains("/Annots"), "rotation retains annotations on the updated page"); + } + for annotations in ["[]", "null"] { + let pdf = test_pdf_with_annotations(&[LETTER, LETTER_LANDSCAPE], false, Some(annotations)); + let sheets = read(&pdf).unwrap(); + let fit = plan(&sheets.sheets, (612.0, 792.0), Fit::Shrink { margin: 18.0 }).unwrap(); + assert!(sheets.update(&fit).is_ok()); + } +} + #[test] fn an_encrypted_file_is_turned_but_not_reframed() { // An /Encrypt entry is all it takes: new content streams would have to be encrypted too. From 8fb89562d1ac3e8a840fe10764f336763a43ffed Mon Sep 17 00:00:00 2001 From: skylartaylor Date: Tue, 13 Oct 2026 02:15:46 +0200 Subject: [PATCH 2/7] Expose shared safe driver settings and RGB print transport --- Cargo.lock | 9 +++++++++ crates/print/Cargo.toml | 4 ++++ crates/print/README.md | 10 ++++++++++ crates/print/src/lib.rs | 7 +++++++ 4 files changed, 30 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 57ecdf5c..db88a61f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -525,6 +525,7 @@ dependencies = [ name = "craft-print" version = "0.1.0" dependencies = [ + "craft-winprint-gdi", "miniz_oxide 0.8.9", "roxmltree", "tempfile", @@ -566,6 +567,14 @@ dependencies = [ "serde_json", ] +[[package]] +name = "craft-winprint-gdi" +version = "0.1.0" +source = "git+https://github.com/storytold/winprint.rs?rev=c32b8e717259d0a7589d301fa80122323b052012#c32b8e717259d0a7589d301fa80122323b052012" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "crc32fast" version = "1.5.2" diff --git a/crates/print/Cargo.toml b/crates/print/Cargo.toml index d5fead90..2788dc9a 100644 --- a/crates/print/Cargo.toml +++ b/crates/print/Cargo.toml @@ -7,6 +7,10 @@ repository.workspace = true rust-version.workspace = true 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 = "c32b8e717259d0a7589d301fa80122323b052012" } + [lints] workspace = true diff --git a/crates/print/README.md b/crates/print/README.md index 1277aae9..b1dcbd23 100644 --- a/crates/print/README.md +++ b/crates/print/README.md @@ -97,3 +97,13 @@ reqwest (with rustls) and sigstore crates as non-optional build-dependencies, us running `cargo deny` need a licence exception for `webpki-root-certs` (CDLA-Permissive-2.0, CA list data reqwest brings) and an advisory ignore for RUSTSEC-2024-0370 (`proc-macro-error` unmaintained, a compile-time proc macro of winprint's `fmt-derive`). + +`craft_print::rgb` exposes the safe `craft-winprint-gdi` API for native driver settings, +printable geometry, RGB pages and caller-composed vector strokes. Applications retain ICC +conversion, document layout, labels and print-mark composition. The shared transport validates +single-copy DEVMODE settings before emitting manual copies and checks GDI errors. Color policy +is explicit: application-managed/unmanaged RGB disables DC and old-style halftone correction +with `ICM_DONE_OUTSIDEDC`, reapplied after `StartPage`; driver-managed RGB uses `ICM_OFF`. +Vendor driver automatic color correction remains a driver setting and requires qualification. +Other platforms return `rgb::PrintError::Unsupported`. The Windows PDF backend and its existing +completion dependency pin are independent of this additional MIT OR Apache-2.0 API. diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index 7c873794..8543652a 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -17,6 +17,13 @@ mod ticket; #[cfg(windows)] mod windows; +/// Native Windows driver settings, printable geometry and explicit RGB/GDI transport. +/// Callers perform ICC conversion and compose page pixels/vector strokes. Other platforms +/// return `rgb::PrintError::Unsupported`; the PDF spooler remains independent of this API. +pub mod rgb { + pub use craft_winprint_gdi::*; +} + /// A system spooler failure. Document permissions belong to the caller. #[derive(Clone, Debug, PartialEq, Eq)] pub enum Error { From ee70afc29a41bc50f2a55affa9aa2c62e0d28e3a Mon Sep 17 00:00:00 2001 From: skylartaylor Date: Tue, 13 Oct 2026 02:49:34 +0200 Subject: [PATCH 3/7] Pin driver copy-validity qualification fix --- Cargo.lock | 2 +- crates/print/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index db88a61f..0ccc0a29 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -570,7 +570,7 @@ dependencies = [ [[package]] name = "craft-winprint-gdi" version = "0.1.0" -source = "git+https://github.com/storytold/winprint.rs?rev=c32b8e717259d0a7589d301fa80122323b052012#c32b8e717259d0a7589d301fa80122323b052012" +source = "git+https://github.com/storytold/winprint.rs?rev=417ac3c4d14d3da5485a99c7d5ce8f05664ec312#417ac3c4d14d3da5485a99c7d5ce8f05664ec312" dependencies = [ "windows-sys 0.61.2", ] diff --git a/crates/print/Cargo.toml b/crates/print/Cargo.toml index 2788dc9a..1f5aa1a8 100644 --- a/crates/print/Cargo.toml +++ b/crates/print/Cargo.toml @@ -9,7 +9,7 @@ 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 = "c32b8e717259d0a7589d301fa80122323b052012" } +craft-winprint-gdi = { git = "https://github.com/storytold/winprint.rs", rev = "417ac3c4d14d3da5485a99c7d5ce8f05664ec312" } [lints] workspace = true From 6ba4d0b97ad09767c588d82ff9c988eea53f0546 Mon Sep 17 00:00:00 2001 From: skylartaylor Date: Tue, 13 Oct 2026 03:29:55 +0200 Subject: [PATCH 4/7] Refuse annotation visibility the native renderer cannot print correctly --- crates/print/README.md | 6 +++++ crates/print/src/sheets.rs | 28 +++++++++++++++++++++++- crates/print/src/tests/windows_native.rs | 16 ++++++++++++++ crates/print/src/windows.rs | 1 + 4 files changed, 50 insertions(+), 1 deletion(-) diff --git a/crates/print/README.md b/crates/print/README.md index b1dcbd23..19d3b239 100644 --- a/crates/print/README.md +++ b/crates/print/README.md @@ -64,6 +64,12 @@ does not provide PostScript output, separations, trapping or colour-managed devi Windows shrink-to-fit refuses pages with annotations when their geometry needs fitting. Callers must flatten annotation appearances into page content first. Exact-size printing and rotation retain the annotations. This avoids losing printed comments, forms or other marks. +The Windows renderer uses viewer visibility. Native submission therefore accepts only plain +visible printable annotation flags (`F=4`); annotations without Print, with NoView, or with +other visibility/rotation flags require caller flattening. This prevents printing comments +that were marked nonprinting or omitting print-only marks. A visible Square appearance with +`F=4` was rendered in the Windows qualification fixture; other appearance types still need +their own fidelity checks. `cargo test -p craft-print` tests CUPS transport (including pipe-sized jobs and refusal), printer parsing, PPD options and argument mapping, and on every platform the Windows pieces that diff --git a/crates/print/src/sheets.rs b/crates/print/src/sheets.rs index 7ba70e1a..d3c989cd 100644 --- a/crates/print/src/sheets.rs +++ b/crates/print/src/sheets.rs @@ -806,6 +806,8 @@ pub(crate) struct Sheet { pub(crate) bounds: [f64; 4], /// `/Rotate`, 0, 90, 180 or 270. pub(crate) rotate: u32, + /// Annotation flags match the renderer's visible printing behavior. + print_annotations_safe: bool, } impl Sheet { @@ -939,7 +941,22 @@ pub(crate) fn read(data: &[u8]) -> Result, Error> { let rotate = here.rotate.as_ref().and_then(|r| reader.resolve(r)).and_then(|r| r.int()).unwrap_or(0).rem_euclid(360); // Not a multiple of 90 is invalid; such a page is shown unrotated. let rotate = if rotate % 90 == 0 { u32::try_from(rotate).unwrap_or(0) } else { 0 }; - sheets.push(Sheet { obj, dict, bounds, rotate }); + // Windows renders viewer visibility, not annotation print visibility. Only a plain + // visible printable annotation is safe; flatten other flags before native submission. + let print_annotations_safe = match get(&dict, b"Annots") { + None | Some(Obj::Null) => true, + Some(annotations) => match reader.resolve(annotations) { + Some(Obj::Null) => true, + Some(Obj::Array(items)) => items.iter().all(|item| { + reader + .resolve(item) + .and_then(|a| a.dict().cloned()) + .is_some_and(|annotation| get(&annotation, b"F").and_then(|f| reader.resolve(f)).and_then(|f| f.int()) == Some(4)) + }), + _ => false, + }, + }; + sheets.push(Sheet { obj, dict, bounds, rotate, print_annotations_safe }); } if reader.budget.exhausted { return Err(unreadable("the parser memory budget was exceeded")); @@ -1042,6 +1059,15 @@ pub(crate) fn plan(sheets: &[Sheet], paper: (f64, f64), fit: Fit) -> Result { + /// Refuse annotation print/view visibility the Windows renderer cannot preserve. + pub(crate) fn check_print_annotations(&self) -> Result<(), Error> { + if self.sheets.iter().all(|s| s.print_annotations_safe) { + Ok(()) + } else { + Err(Error::Spool("flatten annotation print visibility into page content before native Windows printing; nothing was sent".into())) + } + } + /// The PDF with `edits` appended as an incremental update (ISO 32000-1, 7.5.6): the /// original bytes, then the new versions of the edited pages, the content streams a /// reframed page wraps its own in, and a cross-reference section of the same kind as the diff --git a/crates/print/src/tests/windows_native.rs b/crates/print/src/tests/windows_native.rs index 6b226595..5ad28635 100644 --- a/crates/print/src/tests/windows_native.rs +++ b/crates/print/src/tests/windows_native.rs @@ -292,6 +292,22 @@ fn shrink_to_fit_refuses_to_drop_annotation_appearances() { } } +#[test] +fn annotation_print_visibility_is_checked_before_native_submission() { + for flags in [0, 4, 6, 36, 20] { + let annotations = format!("[<< /Subtype /Square /Rect [36 36 72 72] /F {flags} >>]"); + let pdf = test_pdf_with_annotations(&[LETTER], false, Some(&annotations)); + let sheets = crate::sheets::read(&pdf).unwrap(); + assert_eq!(sheets.check_print_annotations().is_ok(), flags == 4, "flags={flags}"); + } + for annotations in ["[]", "null"] { + let pdf = test_pdf_with_annotations(&[LETTER], false, Some(annotations)); + assert!(crate::sheets::read(&pdf).unwrap().check_print_annotations().is_ok()); + } + let pdf = test_pdf(&[LETTER], false); + assert!(crate::sheets::read(&pdf).unwrap().check_print_annotations().is_ok()); +} + #[test] fn an_encrypted_file_is_turned_but_not_reframed() { // An /Encrypt entry is all it takes: new content streams would have to be encrypted too. diff --git a/crates/print/src/windows.rs b/crates/print/src/windows.rs index 68ec4f91..57e2f924 100644 --- a/crates/print/src/windows.rs +++ b/crates/print/src/windows.rs @@ -126,6 +126,7 @@ fn imageable_margin(device: &PrinterDevice, ticket: &PrintTicket) -> f64 { /// queue: a paused queue or an offline printer keeps the job there, as with any program. pub(crate) fn submit(pdf: &[u8], job: &Job) -> Result { let read = sheets::read(pdf)?; + read.check_print_annotations()?; let count = read.sheets.len(); let first = read.sheets.first().map(sheets::Sheet::size).ok_or_else(|| spool_error("the print-ready PDF has no pages"))?; let landscape = first.0 > first.1 + SAME; From 3929c3d14c1e015db1e7aa917d1450e48cff0db4 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Tue, 13 Oct 2026 04:48:42 +0200 Subject: [PATCH 5/7] Refuse explicit print options dropped by Windows drivers --- crates/print/README.md | 7 +++ crates/print/src/tests/windows_native.rs | 10 +++- crates/print/src/ticket.rs | 66 ++++++++++++++++++++++++ crates/print/src/windows.rs | 28 ++++++++-- 4 files changed, 106 insertions(+), 5 deletions(-) diff --git a/crates/print/README.md b/crates/print/README.md index 19d3b239..fefc9f1c 100644 --- a/crates/print/README.md +++ b/crates/print/README.md @@ -113,3 +113,10 @@ with `ICM_DONE_OUTSIDEDC`, reapplied after `StartPage`; driver-managed RGB uses Vendor driver automatic color correction remains a driver setting and requires qualification. Other platforms return `rgb::PrintError::Unsupported`. The Windows PDF backend and its existing completion dependency pin are independent of this additional MIT OR Apache-2.0 API. + +Native Windows PDF submission checks the driver's validated copy count and explicit +simplex/duplex and grayscale requests before sending. Collation must be retained when both +the page count and copy count exceed one. A driver that drops or changes these options is +refused; select supported options or flatten grayscale into the print-ready page content. +Without an explicit duplex request, the queue default applies. Normal color output retains +the driver's color policy. Single-page or single-copy jobs have no collating order to check. diff --git a/crates/print/src/tests/windows_native.rs b/crates/print/src/tests/windows_native.rs index 5ad28635..943491e2 100644 --- a/crates/print/src/tests/windows_native.rs +++ b/crates/print/src/tests/windows_native.rs @@ -411,8 +411,13 @@ fn windows_jobs_reach_the_spooler() { } let listed = spool::printers().into_iter().find(|p| p.name == name); let mixed = test_pdf(&[LETTER, LETTER_LANDSCAPE, LETTER], true); - let job = Job { printer: Some(name.into()), copies: 2, duplex: Some(Duplex::LongEdge), title: "mixed.pdf".into(), ..Job::default() }; + let job = Job { printer: Some(name.into()), title: "mixed.pdf".into(), ..Job::default() }; let sent = spool::submit(&mixed, &job); + let duplex_refused = if listed.as_ref().is_some_and(|p| p.two_sided == Some(false)) { + Some(spool::submit(&mixed, &Job { duplex: Some(Duplex::LongEdge), ..job.clone() })) + } else { + None + }; let tabloid = test_pdf(&[TABLOID, LETTER], false); let shrink = Job { printer: Some(name.into()), scaling: Scaling::ShrinkToFit, title: "shrink".into(), ..Job::default() }; let shrunk = spool::submit(&tabloid, &shrink); @@ -429,6 +434,9 @@ fn windows_jobs_reach_the_spooler() { ); assert!(listed.as_ref().is_some_and(|p| p.two_sided.is_some()), "the new printer is listed, its duplex support known: {listed:?}"); assert_eq!(sent.as_deref(), Ok("spooled 3 sheet(s)"), "{sent:?}"); + if let Some(refused) = duplex_refused { + assert!(matches!(refused, Err(Error::Spool(ref m)) if m.contains("simplex/duplex") && m.contains("nothing was sent")), "{refused:?}"); + } assert_eq!(shrunk.as_deref(), Ok("spooled 2 sheet(s)"), "{shrunk:?}"); assert!(matches!(exact_refused, Err(Error::Spool(ref m)) if m.contains("one paper size")), "{exact_refused:?}"); assert_eq!(queued.as_deref().map(str::trim), Ok("mixed.pdf 3|shrink.pdf 2"), "one job each, all sheets, in the paused queue: {queued:?}"); diff --git a/crates/print/src/ticket.rs b/crates/print/src/ticket.rs index e3838f71..5f8c914b 100644 --- a/crates/print/src/ticket.rs +++ b/crates/print/src/ticket.rs @@ -171,6 +171,41 @@ pub(crate) fn verify_ticket(xml: &str, paper: Option<(u32, u32)>, landscape: boo Ok(size) } +/// Confirm explicit job options survived driver validation. Collation only changes output +/// when both the document and copy count exceed one. Without a duplex request the queue's +/// default applies; without forced grayscale the driver's normal color policy applies. +pub(crate) fn verify_job_options(xml: &str, job: &Job, pages: usize) -> Result<(), Error> { + let document = roxmltree::Document::parse(xml).map_err(spool_error)?; + let root = document.root_element(); + let option = |name: &str| { + root.children() + .find(|n| n.has_tag_name((NS_PSF, "Feature")) && named(*n, name)) + .and_then(|f| f.children().find(|n| n.has_tag_name((NS_PSF, "Option")))) + }; + let refused = |field: &str| spool_error(format!("the printer's driver did not retain the requested {field}; nothing was sent")); + let copies = job.copies.clamp(1, 999); + if integer(root, "ParameterInit", "JobCopiesAllDocuments") != Some(copies) { + return Err(refused("copy count")); + } + if copies > 1 && pages > 1 && !option("DocumentCollate").is_some_and(|n| named(n, if job.collate { "Collated" } else { "Uncollated" })) { + return Err(refused("collation")); + } + if let Some(duplex) = job.duplex { + let wanted = match duplex { + Duplex::Off => "OneSided", + Duplex::LongEdge => "TwoSidedLongEdge", + Duplex::ShortEdge => "TwoSidedShortEdge", + }; + if !option("JobDuplexAllDocumentsContiguously").is_some_and(|n| named(n, wanted)) { + return Err(refused("simplex/duplex setting")); + } + } + if job.grayscale && !option("PageOutputColor").is_some_and(|n| named(n, "Monochrome") || named(n, "Grayscale")) { + return Err(refused("grayscale")); + } + Ok(()) +} + /// The widest unprintable margin of the paper, in micrometres, from PrintCapabilities computed /// for the job's ticket (`PageImageableSize`). The largest of the four, so content centred inside /// it is clear of every edge whichever way the driver measures. `None` when the driver doesn't say. @@ -262,6 +297,37 @@ mod tests { assert!(verify_ticket("2<", ">1<"), ("psk:Collated", "psk:Uncollated"), ("psk:TwoSidedLongEdge", "psk:OneSided"), ("psk:Monochrome", "psk:Color")] + { + assert!(verify_job_options(&xml.replace(from, to), &job, 3).is_err(), "{from}"); + } + assert!(verify_job_options(&xml.replace("psk:Collated", "vendor:Collated"), &job, 3).is_err(), "namespace matters"); + assert!(verify_job_options(&xml.replace("psk:Monochrome", "psk:Grayscale"), &job, 3).is_ok()); + for feature in ["DocumentCollate", "JobDuplexAllDocumentsContiguously", "PageOutputColor"] { + let start = xml.find(&format!("").unwrap() + "".len(); + let mut dropped = xml.clone(); + dropped.replace_range(start..end, ""); + assert!(verify_job_options(&dropped, &job, 3).is_err(), "{feature}"); + if feature == "DocumentCollate" { + assert!(verify_job_options(&dropped, &job, 1).is_ok(), "one page has no collating order"); + } + } + let default = Job::default(); + let xml = ticket_xml(&default, false); + assert!(verify_job_options(&xml, &default, 3).is_ok()); + let simplex = Job { duplex: Some(Duplex::Off), ..Job::default() }; + assert!(verify_job_options(&ticket_xml(&simplex, false), &simplex, 3).is_ok()); + assert!(verify_job_options(&xml, &simplex, 3).is_err(), "explicit simplex must be confirmed"); + assert!(verify_job_options(" Vec { /// Whether the printer's driver takes two-sided settings: its PrintCapabilities offer a two-sided /// option for the feature the job's ticket sets. `None` when the capabilities can't be read. A -/// driver without one drops two-sided from the ticket and prints its own way. +/// driver without one cannot confirm an explicit two-sided request, which is refused before submission. fn two_sided(device: &PrinterDevice) -> Option { let capabilities = PrintCapabilities::fetch(device).ok()?; let duplex = OwnedName::qualified("JobDuplexAllDocumentsContiguously", NS_PSK, Some("psk")); @@ -146,6 +146,8 @@ pub(crate) fn submit(pdf: &[u8], job: &Job) -> Result { .name("print job".into()) .spawn_scoped(s, move || { let (ticket, paper) = driver_ticket(&device, job, first, landscape)?; + let xml = std::str::from_utf8(ticket.get_xml()).map_err(spool_error)?; + ticket::verify_job_options(xml, job, count)?; let (fit, paper) = match job.scaling { Scaling::Exact => (Fit::Exact, first), Scaling::ShrinkToFit => { @@ -187,21 +189,39 @@ mod tests { assert!(select_device(Vec::new(), None, None).is_err()); } - /// What a real driver makes of a job's ticket: Letter, portrait, two copies, two-sided on - /// the long edge, grayscale. Sends no job. `CRAFT_PRINT_TEST_PRINTER` names the queue; + /// What a real driver makes of a job's ticket: Letter, portrait, two copies. Checks explicit + /// option retention separately; unsupported options must fail before submission. Sends no job. + /// `CRAFT_PRINT_TEST_PRINTER` names the queue; /// `CRAFT_PRINT_TEST_TICKET_OUTPUT` keeps the validated ticket, to see which options it kept. #[test] #[ignore = "needs CRAFT_PRINT_TEST_PRINTER naming an installed printer; sends no job"] fn an_installed_driver_validates_the_ticket() { let name = std::env::var("CRAFT_PRINT_TEST_PRINTER").unwrap(); let device = select_device(PrinterDevice::all().unwrap(), Some(&name), None).unwrap(); - let job = Job { copies: 2, duplex: Some(crate::Duplex::LongEdge), grayscale: true, ..Job::default() }; + let job = Job { copies: 2, ..Job::default() }; let (ticket, paper) = driver_ticket(&device, &job, (612.0, 792.0), false).unwrap(); + ticket::verify_job_options(std::str::from_utf8(ticket.get_xml()).unwrap(), &job, 1).unwrap(); assert_eq!(paper, (215_900, 279_400)); eprintln!("unprintable margin: {} pt", imageable_margin(&device, &ticket)); if let Ok(path) = std::env::var("CRAFT_PRINT_TEST_TICKET_OUTPUT") { std::fs::write(path, ticket.get_xml()).unwrap(); } + let capabilities = PrintCapabilities::fetch(&device).unwrap(); + for (feature, wanted, requested) in [ + ("PageOutputColor", "Monochrome", Job { grayscale: true, ..Job::default() }), + ("JobDuplexAllDocumentsContiguously", "TwoSidedLongEdge", Job { duplex: Some(crate::Duplex::LongEdge), ..Job::default() }), + ("DocumentCollate", "Collated", Job { copies: 2, ..Job::default() }), + ] { + let (ticket, _) = driver_ticket(&device, &requested, (612.0, 792.0), false).unwrap(); + let retained = ticket::verify_job_options(std::str::from_utf8(ticket.get_xml()).unwrap(), &requested, 3); + eprintln!("{feature}={wanted}: {retained:?}"); + let offered = capabilities + .options_for_feature(OwnedName::qualified(feature, NS_PSK, Some("psk"))) + .any(|o| o.name.as_ref().is_some_and(|n| n.local_name == wanted)); + if !offered { + assert!(retained.is_err(), "unsupported explicit option must not be silently dropped"); + } + } } /// A real job, for checking a printer by hand (paper, or a paused queue's spool file): From d873514e8a773f02f54c34ba890e57c02c3a36f6 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Tue, 13 Oct 2026 04:50:19 +0200 Subject: [PATCH 6/7] Document driver option refusal and equivalent grayscale capability --- crates/print/README.md | 18 +++++++++--------- crates/print/src/windows.rs | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/crates/print/README.md b/crates/print/README.md index fefc9f1c..a50ba0d7 100644 --- a/crates/print/README.md +++ b/crates/print/README.md @@ -71,6 +71,13 @@ that were marked nonprinting or omitting print-only marks. A visible Square appe `F=4` was rendered in the Windows qualification fixture; other appearance types still need their own fidelity checks. +Native Windows PDF submission checks the driver's validated copy count and explicit +simplex/duplex and grayscale requests before sending. Collation must be retained when both +the page count and copy count exceed one. A driver that drops or changes these options is +refused; select supported options or flatten grayscale into the print-ready page content. +Without an explicit duplex request, the queue default applies. Normal color output retains +the driver's color policy. Single-page or single-copy jobs have no collating order to check. + `cargo test -p craft-print` tests CUPS transport (including pipe-sized jobs and refusal), printer parsing, PPD options and argument mapping, and on every platform the Windows pieces that need no printer: the PrintTicket, the checks on the driver's validated ticket and capabilities, @@ -92,8 +99,8 @@ code changes, and supports manual runs. The Windows job executes the spooler reg physical printer fidelity still requires hardware verification. Windows discovery reports driver duplex support in `Printer::two_sided` (a two-sided option in -the driver's PrintCapabilities); CUPS leaves it unknown. Callers can warn when a Windows driver -ignores the job’s duplex choice. +the driver's PrintCapabilities); CUPS leaves it unknown. Explicit simplex/duplex choices that the driver +drops are refused before submission. Windows dependencies: the maintained [`winprint` fork](https://github.com/storytold/winprint.rs) (BSD-3-Clause, pinned to an immutable Git revision, its PDFium-downloading `pdfium` feature off) and `winsafe` (MIT) hold every Win32 and WinRT call, so the crate keeps `unsafe` forbidden; @@ -113,10 +120,3 @@ with `ICM_DONE_OUTSIDEDC`, reapplied after `StartPage`; driver-managed RGB uses Vendor driver automatic color correction remains a driver setting and requires qualification. Other platforms return `rgb::PrintError::Unsupported`. The Windows PDF backend and its existing completion dependency pin are independent of this additional MIT OR Apache-2.0 API. - -Native Windows PDF submission checks the driver's validated copy count and explicit -simplex/duplex and grayscale requests before sending. Collation must be retained when both -the page count and copy count exceed one. A driver that drops or changes these options is -refused; select supported options or flatten grayscale into the print-ready page content. -Without an explicit duplex request, the queue default applies. Normal color output retains -the driver's color policy. Single-page or single-copy jobs have no collating order to check. diff --git a/crates/print/src/windows.rs b/crates/print/src/windows.rs index a2c36c92..10096d51 100644 --- a/crates/print/src/windows.rs +++ b/crates/print/src/windows.rs @@ -217,7 +217,7 @@ mod tests { eprintln!("{feature}={wanted}: {retained:?}"); let offered = capabilities .options_for_feature(OwnedName::qualified(feature, NS_PSK, Some("psk"))) - .any(|o| o.name.as_ref().is_some_and(|n| n.local_name == wanted)); + .any(|o| o.name.as_ref().is_some_and(|n| n.local_name == wanted || (feature == "PageOutputColor" && n.local_name == "Grayscale"))); if !offered { assert!(retained.is_err(), "unsupported explicit option must not be silently dropped"); } From 8b8576f0b019b0cb520fe606caf307ca32ab5a03 Mon Sep 17 00:00:00 2001 From: skylartaylor Date: Wed, 14 Oct 2026 06:21:15 +0200 Subject: [PATCH 7/7] Use fixed-size chunks in the CMS total-ink regression --- crates/cms/tests/fogra39.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/cms/tests/fogra39.rs b/crates/cms/tests/fogra39.rs index f7fcdd0c..f958ffc5 100644 --- a/crates/cms/tests/fogra39.rs +++ b/crates/cms/tests/fogra39.rs @@ -545,8 +545,13 @@ fn stand_in_separations_respect_the_ink_limit() { let t = Transform::new(Builtin::Srgb.profile(), cmyk, intent, true).unwrap(); let mut out = vec![0.0f32; N * N * N * 4]; t.convert_f32(&grid, 3, &mut out, 4, false).unwrap(); - let (i, max) = - out.chunks_exact(4).map(|c| c.iter().sum::()).enumerate().fold((0, 0.0f32), |m, (i, s)| if s > m.1 { (i, s) } else { m }); + let (i, max) = out + .as_chunks::<4>() + .0 + .iter() + .map(|c| c.iter().sum::()) + .enumerate() + .fold((0, 0.0f32), |m, (i, s)| if s > m.1 { (i, s) } else { m }); eprintln!("{name}, {intent:?}: max total ink {:.2} % at sRGB {:?}", max * 100.0, &grid[i * 3..i * 3 + 3]); assert!(max <= LIMIT, "{name}, {intent:?}: total ink {max} at sRGB {:?}", &grid[i * 3..i * 3 + 3]);