Merge pull request #42: qualify native Windows printing

Validate native Windows submission and share RGB printing
This commit is contained in:
skylar taylor-barrick authored and GitHub committed 2026-10-13 22:38:03 -06:00
commit 457a078809
9 files changed
+241 -11

No files matched your search

Generated
+9
View File
@@ -533,6 +533,7 @@ dependencies = [
name = "craft-print"
version = "0.1.0"
dependencies = [
"craft-winprint-gdi",
"miniz_oxide 0.8.9",
"roxmltree",
"tempfile",
@@ -574,6 +575,14 @@ dependencies = [
"serde_json",
]
[[package]]
name = "craft-winprint-gdi"
version = "0.1.0"
source = "git+https://github.com/storytold/winprint.rs?rev=417ac3c4d14d3da5485a99c7d5ce8f05664ec312#417ac3c4d14d3da5485a99c7d5ce8f05664ec312"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
+7 -2
View File
@@ -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::<f32>()).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::<f32>())
.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]);
+4
View File
@@ -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 = "417ac3c4d14d3da5485a99c7d5ce8f05664ec312" }
[lints]
workspace = true
+29 -2
View File
@@ -69,6 +69,23 @@ 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.
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.
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,
@@ -90,8 +107,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;
@@ -101,3 +118,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.
+7
View File
@@ -19,6 +19,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 {
+36 -1
View File
@@ -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<Sheets<'_>, 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<Vec<
const OLD_GEOMETRY: [&[u8]; 6] = [b"MediaBox", b"CropBox", b"BleedBox", b"TrimBox", b"ArtBox", b"Rotate"];
impl Sheets<'_> {
/// 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
@@ -1068,6 +1094,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!(
+58 -2
View File
@@ -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<u8> {
test_pdf_with_annotations(pages, compressed, None)
}
fn test_pdf_with_annotations(pages: &[TestPage], compressed: bool, annotations: Option<&str>) -> Vec<u8> {
let n = pages.len();
// 1 catalog, 2 page tree, 3 content, then the pages.
let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", 4 + i)).collect();
@@ -69,6 +73,9 @@ fn test_pdf(pages: &[TestPage], compressed: bool) -> Vec<u8> {
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,43 @@ 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 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.
@@ -367,16 +411,24 @@ 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 caller = std::thread::current().id();
// Rc proves callers are not required to make their callback Send just because native
// printing uses a COM worker. Counts describe PDF sheets, despite copies and duplex.
// printing uses a COM worker. Counts describe accepted PDF sheets.
let processed = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
let observed = std::rc::Rc::clone(&processed);
let sent = spool::submit_with_progress(&mixed, &job, &mut move |done, total| {
assert_eq!(std::thread::current().id(), caller);
observed.borrow_mut().push((done, total));
});
let mut duplex_progress = Vec::new();
let duplex_refused = if listed.as_ref().is_some_and(|p| p.two_sided == Some(false)) {
Some(spool::submit_with_progress(&mixed, &Job { duplex: Some(Duplex::LongEdge), ..job.clone() }, &mut |done, total| {
duplex_progress.push((done, total));
}))
} 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);
@@ -396,7 +448,11 @@ 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!(*processed.borrow(), [(0, 3), (1, 3), (2, 3), (3, 3)]);
assert!(duplex_progress.is_empty(), "driver refusal before submission has no progress");
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!(refused_progress.is_empty(), "rejection before a job starts has no progress: {refused_progress:?}");
+66
View File
@@ -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("<not xml", Some(letter), false).is_err());
}
#[test]
fn a_driver_that_drops_explicit_job_options_is_refused() {
let job = Job { copies: 2, duplex: Some(Duplex::LongEdge), grayscale: true, ..Job::default() };
let xml = ticket_xml(&job, false);
assert!(verify_job_options(&xml, &job, 3).is_ok());
for (from, to) in
[(">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!("<psf:Feature name=\"psk:{feature}\"")).unwrap();
let end = start + xml[start..].find("</psf:Feature>").unwrap() + "</psf:Feature>".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("<not xml", &default, 1).is_err());
}
#[test]
fn the_driver_paper_named_like_the_standard_size_is_preferred() {
let paper = |width, height, name, keyword| Paper { width, height, name, keyword };
+25 -4
View File
@@ -38,7 +38,7 @@ pub(crate) fn printers() -> Vec<Printer> {
/// 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<bool> {
let capabilities = PrintCapabilities::fetch(device).ok()?;
let duplex = OwnedName::qualified("JobDuplexAllDocumentsContiguously", NS_PSK, Some("psk"));
@@ -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_with_progress(pdf: &[u8], job: &Job, progress: &mut dyn FnMut(usize, usize)) -> Result<String, Error> {
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;
@@ -142,6 +143,8 @@ pub(crate) fn submit_with_progress(pdf: &[u8], job: &Job, progress: &mut dyn FnM
// The worker is joined before `dir` is dropped: the spooler reads the file.
let result = crate::progress::run(progress, move |sender| {
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 => {
@@ -185,21 +188,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 || (feature == "PageOutputColor" && n.local_name == "Grayscale")));
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):