collective

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

print: list the paper sizes a Windows driver offers (printer_papers)

print: list the paper sizes a Windows driver offers (printer_papers) ## What this changes `craft_print::printer_papers(printer)`: the paper sizes a Windows driver offers, from its PrintCapabilities page media sizes, keyed by their Print Schema or vendor name (`NorthAmericaLetter`, `NorthAmerica4x6`), named by their display name, in points as the driver has them (a label's width is across the feed, so 2.25 × 1.25 and 1.25 × 2.25 stay distinct); roll media, sizes beyond PDF's 200-inch page limit and repeated keywords are left out, and the list is capped at 400. Empty on CUPS (the queue's PPD holds them; storytold/pdfcraft reads it beside the driver options) and

Status
open
Score
0.00
Adopters
0 other operators
Author
MaxFairwinds (imported from pull request #155, open)
Base
1f805378d454dac48b5c214029d254055fdf961b
Size
+465 −50 in 7 files
Review
pending
Shared
2026-10-28
Id
659e668b16ef76f8b7ef7af0

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/tests/windows_native.rs crates/print/src/ticket.rs crates/print/src/windows.rs

diff --git a/Cargo.lock b/Cargo.lock--- a/Cargo.lock+++ b/Cargo.lock@@ -743,7 +743,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/MaxFairwinds/winprint.rs?rev=3fc968789cf6a7e43c546d8113ef3850bc82dd7a#3fc968789cf6a7e43c546d8113ef3850bc82dd7a" dependencies = [  "windows-sys 0.61.2", ]@@ -5758,7 +5758,7 @@ dependencies = [ [[package]] name = "winprint" version = "0.2.1"-source = "git+https://github.com/storytold/winprint.rs?rev=5f80b9baf6b58937c401656634e42d33f00ddf49#5f80b9baf6b58937c401656634e42d33f00ddf49"+source = "git+https://github.com/MaxFairwinds/winprint.rs?rev=3fc968789cf6a7e43c546d8113ef3850bc82dd7a#3fc968789cf6a7e43c546d8113ef3850bc82dd7a" dependencies = [  "flate2",  "fmt-derive",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,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 = "417ac3c4d14d3da5485a99c7d5ce8f05664ec312" }+craft-winprint-gdi = { git = "https://github.com/MaxFairwinds/winprint.rs", rev = "3fc968789cf6a7e43c546d8113ef3850bc82dd7a" }  [lints] workspace = true@@ -21,7 +21,9 @@ workspace = true # feature downloads PDFium at build time and is off. The maintained BSD-3-Clause fork # preserves terminal failures/cancellation and bounds the completion notification wait. # Pinned to an immutable revision; review before bumping.-winprint = { git = "https://github.com/storytold/winprint.rs", rev = "5f80b9baf6b58937c401656634e42d33f00ddf49", default-features = false }+# storytold/winprint.rs main plus PrintTicket::user_default and PrintCapabilities::fetch_for_ticket+# (MaxFairwinds/winprint.rs feat/user-default-ticket; to move to storytold/winprint.rs once merged).+winprint = { git = "https://github.com/MaxFairwinds/winprint.rs", rev = "3fc968789cf6a7e43c546d8113ef3850bc82dd7a", default-features = false } # GetDefaultPrinter (MIT). winsafe = { version = "0.0.29", default-features = false, features = ["winspool"] } # The job file's private folder.diff --git a/crates/print/README.md b/crates/print/README.md--- a/crates/print/README.md+++ b/crates/print/README.md@@ -22,7 +22,8 @@ opens Windows printer preferences and submits jobs with copies, collation, duple   into the XPS print API (the `winprint` crate), and copies, collation, duplex, colour, paper,   orientation and "no scaling" travel in a PrintTicket that the driver validates first; a   driver that would change the paper, orientation or scaling is refused before anything is-  sent. Paper is the driver's own option for the sheet's size (to 0.25 mm), preferring the one+  sent. A driver that offers no PageScaling feature at all (Zebra's ZDesigner label drivers)+  is not asked for one and not held to its answer: asked for None, it answers CustomSquare. 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 job has one ticket, so one paper and orientation. Sheets of the other orientation are@@ -45,6 +46,22 @@ opens Windows printer preferences and submits jobs with copies, collation, duple - **Browser/other targets:** discovery is empty and submission reports an unsupported   platform; callers can offer PDF export instead. +The paper a job comes out on is checked on the DEVMODE winprint prints with (the ticket+converted on the user's own settings, then settled by the driver), not only on the merged+ticket, which Windows builds on the printer's Printing Defaults: a label driver's one+"User defined" stock is whatever each dialog says, and when a user's Printing Preferences+and the printer's Printing Defaults disagree the merged ticket names the defaults' size+while the job prints on the preferences'. When the driver settles on the paper asked for,+the ticket asked for is printed with. A driver with no two-sided feature at all (a label+printer) is not asked to confirm one-sided printing: it can print nothing else; two-sided+asked of it is refused. Every ticket and capabilities request starts from the signed-in user's own settings+(winprint's `PrintTicket::user_default`, their Printing Preferences), not the printer's+Printing Defaults, which Windows' Print Ticket calls fall back to: on a Zebra ZD500 the+defaults held another stock, another darkness and tear-off where the user had set cutting,+and the driver's private settings ride in the ticket (`PageDevmodeSnapshot`). A sheet is turned (landscape) only when its shape differs from the+paper's as the driver feeds it: a wide sheet on upright office paper is, a wide sheet on a+label stock that is itself wider than tall is not.+ Windows jobs briefly use a file in a private temporary folder (random name, this user's only), named after the job's title because the spooler shows it as the document name; the folder is removed once the spooler has the job, on success and failure. Failed and canceled terminal@@ -65,7 +82,15 @@ change an application's UI. Receipts are backend messages, not portable job IDs. `Job::duplex = None` preserves the queue's duplex default; `Some(Duplex::Off)` forces simplex. `Job::scaling` defaults to `Scaling::Exact`; callers exporting arbitrary document sizes can choose `Scaling::ShrinkToFit`. Exact mode reports-a driver paper-size mismatch. On Windows `command_preview` describes the in-process job+a driver paper-size mismatch. `printer_papers` lists the sizes a driver offers (Windows:+its PrintCapabilities page media sizes, keyed by Print Schema or vendor name, in points, as+the driver has them: a label's width is across the feed; the driver's current default marked;+only the driver's own sizes, so a form added only in Print Server Properties is not among them+unless the driver offers it),+so an app's Print dialog can offer them, start on the driver's default and lay the sheets out+on one the driver has; on CUPS it is empty, the queue's PPD+(`*PaperDimension`) holding them instead.+On Windows `command_preview` describes the in-process job (`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. diff --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@@ -323,6 +323,59 @@ pub fn printer_options(printer: &str) -> Vec<PrinterOption> {     } } +/// A paper size a printer's driver offers, as a Print dialog lists it beside the standard sizes.+#[derive(Clone, Debug, PartialEq)]+pub struct PrinterPaper {+    /// The driver's keyword for it: on Windows the Print Schema name (`NorthAmericaLetter`,+    /// `NorthAmerica4x6`) or the vendor's own.+    pub key: String,+    /// What the driver calls it (`Letter`, `4 x 6 in`); the keyword when it gives no name.+    pub label: String,+    /// Width and height in points as the driver has them: a label's width is across the feed,+    /// so a 2.25 × 1.25 in label and a 1.25 × 2.25 in one are different stock.+    pub size: (f64, f64),+    /// Whether it is the driver's current default: the stock a label printer is set up for, an+    /// office printer's tray paper. A Print dialog starts on it.+    pub default: bool,+}++/// How many papers a driver may put in the list.+pub const MAX_PRINTER_PAPERS: usize = 400;++/// The paper sizes a printer's driver offers, in the driver's order: on Windows its+/// PrintCapabilities page media sizes (roll media and sizes beyond 200 inches left out), the+/// driver's current default marked. Empty+/// when the printer or its capabilities can't be read, and on other platforms, where the CUPS+/// queue's PPD holds them (`*PaperDimension`; the apps read it with their driver options).+/// Reads the driver, so call it when a printer is chosen, not every frame.+pub fn printer_papers(printer: &str) -> Vec<PrinterPaper> {+    #[cfg(windows)]+    {+        windows::printer_papers(printer)+    }+    #[cfg(not(windows))]+    {+        let _ = printer;+        Vec::new()+    }+}++/// Whether `printer` is a thermal label printer: its driver offers only monochrome output and the+/// paper in the user's settings is label-sized (at most 4.5 in on its short side). Such drivers+/// dither every grey they are given, so a caller sends them black-and-white pictures at the+/// printer's resolution through [`rgb::print_pages`] instead of a PDF (Windows; false elsewhere).+pub fn is_label_printer(printer: &str) -> bool {+    #[cfg(windows)]+    {+        windows::is_label_printer(printer)+    }+    #[cfg(not(windows))]+    {+        let _ = printer;+        false+    }+}+ /// Whether [`open_printer_preferences`] can show the driver's own settings window (Windows). pub const HAS_PRINTER_PREFERENCES: bool = cfg!(windows); diff --git a/crates/print/src/tests/windows_native.rs b/crates/print/src/tests/windows_native.rs--- a/crates/print/src/tests/windows_native.rs+++ b/crates/print/src/tests/windows_native.rs@@ -31,6 +31,27 @@ fn windows_printers_are_sorted_once_with_their_default() {     assert!(spool::printer_list([("Office".to_string(), None)], None).iter().all(|p| !p.default)); } +/// The driver's paper sizes come from its capabilities (Windows: the in-box PDF printer offers+/// Letter and A4, keyed by Print Schema names, in points, as the driver has them); an unknown+/// printer and the other platforms list none.+#[test]+fn printer_papers_come_from_the_drivers_capabilities() {+    assert!(spool::printer_papers("No Such Printer (craft-print test)").is_empty());+    assert!(spool::printer_papers("").is_empty());+    #[cfg(windows)]+    {+        let Some(printer) = spool::printers().into_iter().find(|p| p.name == "Microsoft Print to PDF") else { return };+        let papers = spool::printer_papers(&printer.name);+        assert!(papers.len() > 1, "{papers:?}");+        let letter = papers.iter().find(|p| p.key == "NorthAmericaLetter").expect("Letter by its Print Schema name");+        assert!((letter.size.0 - 612.0).abs() < 1.0 && (letter.size.1 - 792.0).abs() < 1.0 && !letter.label.is_empty(), "{letter:?}");+        assert!(papers.iter().all(|p| !p.key.is_empty() && p.size.0 >= 1.0 && p.size.1 >= 1.0), "no roll media");+        let keys: std::collections::BTreeSet<&str> = papers.iter().map(|p| p.key.as_str()).collect();+        assert_eq!(keys.len(), papers.len(), "no repeated keyword");+        assert_eq!(papers.iter().filter(|p| p.default).count(), 1, "the driver's default paper is marked, once: {papers:?}");+    }+}+ /// One page of a test PDF: its MediaBox (`None` = inherited from the page tree's root, which is /// Letter), CropBox and `/Rotate`. #[derive(Clone, Copy)]diff --git a/crates/print/src/ticket.rs b/crates/print/src/ticket.rs--- a/crates/print/src/ticket.rs+++ b/crates/print/src/ticket.rs@@ -144,7 +144,9 @@ fn integer(node: roxmltree::Node<'_, '_>, tag: &str, name: &str) -> Option<u32> /// paper (when `paper` is given, that one, to a quarter of a millimetre). Returns the paper's /// width and height in micrometres as the driver lists it. Only those schema fields are read: /// some drivers return empty vendor parameters that a stricter parser would refuse.-pub(crate) fn verify_ticket(xml: &str, paper: Option<(u32, u32)>, landscape: bool) -> Result<(u32, u32), Error> {+/// `scaling_offered`: whether the driver's capabilities have a PageScaling feature; only then is its+/// answer about scaling checked.+pub(crate) fn verify_ticket(xml: &str, paper: Option<(u32, u32)>, landscape: bool, scaling_offered: bool) -> Result<(u32, u32), Error> {     let document = roxmltree::Document::parse(xml).map_err(spool_error)?;     let feature = |name: &str| document.root_element().children().find(|n| n.has_tag_name((NS_PSF, "Feature")) && named(*n, name));     let media = feature("PageMediaSize").ok_or_else(|| spool_error("the printer's driver did not confirm the paper size; nothing was sent"))?;@@ -162,7 +164,9 @@ pub(crate) fn verify_ticket(xml: &str, paper: Option<(u32, u32)>, landscape: boo     if !orientation.is_some_and(|n| named(n, if landscape { "Landscape" } else { "Portrait" })) {         return Err(spool_error("the printer's driver changed the orientation; nothing was sent"));     }-    if let Some(scaling) = feature("PageScaling") {+    // Only a driver that offers PageScaling can scale by it; one that doesn't (Zebra's ZDesigner)+    // was not asked, and whatever the merge put there is not a choice of its own.+    if let Some(scaling) = feature("PageScaling").filter(|_| scaling_offered) {         // Microsoft's IPP class driver names its "None" in a namespace of its own.         if !scaling.children().any(|n| n.has_tag_name((NS_PSF, "Option")) && n.attribute("name").and_then(|v| v.rsplit(':').next()) == Some("None")) {             return Err(spool_error("the printer's driver would scale the sheets; nothing was sent"));@@ -173,8 +177,11 @@ pub(crate) fn verify_ticket(xml: &str, paper: Option<(u32, u32)>, landscape: boo  /// 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> {+/// default applies; without forced grayscale the driver's normal color policy applies. A+/// driver that offers no two-sided feature at all (`duplex_offered` false: a label printer)+/// can only print one side, so one-sided asked of it has nothing to confirm; two-sided asked+/// of it is refused as any dropped option is.+pub(crate) fn verify_job_options(xml: &str, job: &Job, pages: usize, duplex_offered: bool) -> Result<(), Error> {     let document = roxmltree::Document::parse(xml).map_err(spool_error)?;     let root = document.root_element();     let option = |name: &str| {@@ -196,7 +203,8 @@ pub(crate) fn verify_job_options(xml: &str, job: &Job, pages: usize) -> Result<(             Duplex::LongEdge => "TwoSidedLongEdge",             Duplex::ShortEdge => "TwoSidedShortEdge",         };-        if !option("JobDuplexAllDocumentsContiguously").is_some_and(|n| named(n, wanted)) {+        let confirmed = option("JobDuplexAllDocumentsContiguously").is_some_and(|n| named(n, wanted));+        if !confirmed && !(duplex == Duplex::Off && !duplex_offered) {             return Err(refused("simplex/duplex setting"));         }     }@@ -206,6 +214,69 @@ pub(crate) fn verify_job_options(xml: &str, job: &Job, pages: usize) -> Result<(     Ok(()) } +/// Whether a sheet is turned on the paper: `sheet_landscape` says the sheet is wider than tall,+/// `paper` is the driver's paper in micrometres as the driver feeds it. An upright paper (office+/// sizes) turns a wide sheet; a paper that is itself wider than tall (a label stock defined across+/// the feed) takes a wide sheet as it is and turns an upright one. Without a known paper the+/// sheet's own shape decides, as for upright paper.+pub(crate) fn turned(sheet_landscape: bool, paper: Option<(u32, u32)>) -> bool {+    match paper {+        Some((w, h)) if w > h => !sheet_landscape,+        _ => sheet_landscape,+    }+}++/// The paper a DEVMODE prints on, in micrometres, and whether it is landscape: what the driver+/// settled on when the ticket was converted for printing (winprint's `PrintTicket::to_dev_mode`:+/// the user's own settings as the base, then the driver's validation). `None` when the DEVMODE+/// names no paper size (a roll, a driver that leaves the fields unset).+pub(crate) fn devmode_paper(dev_mode: &[u8]) -> Option<((u32, u32), bool)> {+    const DM_ORIENTATION: u32 = 0x1;+    const DM_PAPERLENGTH: u32 = 0x4;+    const DM_PAPERWIDTH: u32 = 0x8;+    const DMORIENT_LANDSCAPE: i16 = 2;+    let i16_at = |at: usize| dev_mode.get(at..at + 2).and_then(|b| <[u8; 2]>::try_from(b).ok()).map(i16::from_le_bytes);+    let fields = dev_mode.get(72..76).and_then(|b| <[u8; 4]>::try_from(b).ok()).map(u32::from_le_bytes)?;+    if fields & (DM_PAPERWIDTH | DM_PAPERLENGTH) != DM_PAPERWIDTH | DM_PAPERLENGTH {+        return None;+    }+    let (width, length) = (i16_at(82)?, i16_at(80)?);+    if width <= 0 || length <= 0 {+        return None;+    }+    let landscape = fields & DM_ORIENTATION != 0 && i16_at(76)? == DMORIENT_LANDSCAPE;+    // Tenths of a millimetre, as DEVMODE has them, to micrometres.+    Some(((u32::from(width.unsigned_abs()) * 100, u32::from(length.unsigned_abs()) * 100), landscape))+}++/// The longest short side of a label printer's paper, in micrometres (4.5 in).+pub(crate) const LABEL_MAX_SHORT_SIDE: u32 = 114_300;++/// Whether a driver offering `colours` (PageOutputColor option keywords) on `paper` (micrometres)+/// is a thermal label printer: monochrome only, label-sized paper. A monochrome office printer+/// or a fax has Letter or A4; a label printer that also offers colour is left to its driver.+pub(crate) fn label_shaped(colours: &[&str], paper: Option<(u32, u32)>) -> bool {+    !colours.is_empty()+        && colours.iter().all(|c| *c == "Monochrome")+        && paper.is_some_and(|(w, h)| w > 0 && h > 0 && w.min(h) <= LABEL_MAX_SHORT_SIDE)+}++/// A ticket's PageMediaSize, in micrometres; `None` without one or for a roll.+pub(crate) fn media_size(xml: &str) -> Option<(u32, u32)> {+    let document = roxmltree::Document::parse(xml).ok()?;+    let media = document.root_element().children().find(|n| n.has_tag_name((NS_PSF, "Feature")) && named(*n, "PageMediaSize"))?;+    integer(media, "ScoredProperty", "MediaSizeWidth").zip(integer(media, "ScoredProperty", "MediaSizeHeight")).filter(|(w, h)| *w > 0 && *h > 0)+}++/// The keyword of a ticket's PageMediaSize option, without its namespace prefix+/// (`NorthAmericaLetter`, a vendor's `User0000000256`): what [`crate::PrinterPaper::key`] holds.+pub(crate) fn media_keyword(xml: &str) -> Option<String> {+    let document = roxmltree::Document::parse(xml).ok()?;+    let media = document.root_element().children().find(|n| n.has_tag_name((NS_PSF, "Feature")) && named(*n, "PageMediaSize"))?;+    let option = media.children().find(|n| n.has_tag_name((NS_PSF, "Option")))?;+    option.attribute("name").and_then(|v| v.rsplit(':').next()).filter(|k| !k.is_empty()).map(str::to_string)+}+ /// 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.@@ -265,6 +336,16 @@ mod tests {         assert!(!ticket_xml(&Job { printer: Some("Office \"east\" <1>".into()), ..Job::default() }, false).contains("east"));     } +    #[test]+    fn the_media_keyword_is_the_tickets_page_media_size_option() {+        assert_eq!(media_keyword(&validated((215_900, 279_400), "psk:Portrait", None)).as_deref(), Some("NorthAmericaLetter"));+        assert_eq!(+            media_keyword(r#"<psf:PrintTicket version="1" xmlns:psf="http://schemas.microsoft.com/windows/2003/08/printing/printschemaframework"/>"#),+            None+        );+        assert_eq!(media_keyword("not xml"), None);+    }+     /// A validated ticket as a driver returns it.     fn validated(paper: (u32, u32), orientation: &str, scaling: Option<&str>) -> String {         let scaling = scaling.map_or(String::new(), |s| format!(r#"<psf:Feature name="psk:PageScaling"><psf:Option name="{s}"/></psf:Feature>"#));@@ -285,47 +366,113 @@ mod tests {     fn a_driver_that_changes_the_sheet_is_refused() {         let letter = (215_900, 279_400);         let ticket = validated(letter, "Portrait", Some("psk:None"));-        assert_eq!(verify_ticket(&ticket, Some(letter), false), Ok(letter));-        assert_eq!(verify_ticket(&ticket, Some((279_400, 215_900)), false), Ok(letter), "either way round");-        assert_eq!(verify_ticket(&ticket, None, false), Ok(letter), "shrink-to-fit takes the driver's paper");-        assert!(verify_ticket(&ticket, Some((210_000, 297_000)), false).is_err(), "A4 asked, Letter returned");-        assert!(verify_ticket(&ticket, Some(letter), true).is_err(), "landscape asked, portrait returned");-        assert!(verify_ticket(&validated(letter, "Portrait", Some("psk:FitApplicationBleedSizeToPageImageableSize")), Some(letter), false).is_err());-        assert!(verify_ticket(&validated(letter, "Portrait", Some("ns0000:None")), Some(letter), false).is_ok(), "the IPP class driver's None");-        assert!(verify_ticket(&validated(letter, "Portrait", None), Some(letter), false).is_ok(), "a driver without scaling");-        assert!(verify_ticket(&validated((0, 279_400), "Portrait", None), None, false).is_err(), "a roll has no length");-        assert!(verify_ticket("<not xml", Some(letter), false).is_err());+        assert_eq!(verify_ticket(&ticket, Some(letter), false, true), Ok(letter));+        assert_eq!(verify_ticket(&ticket, Some((279_400, 215_900)), false, true), Ok(letter), "either way round");+        assert_eq!(verify_ticket(&ticket, None, false, true), Ok(letter), "shrink-to-fit takes the driver's paper");+        assert!(verify_ticket(&ticket, Some((210_000, 297_000)), false, true).is_err(), "A4 asked, Letter returned");+        assert!(verify_ticket(&ticket, Some(letter), true, true).is_err(), "landscape asked, portrait returned");+        assert!(+            verify_ticket(&validated(letter, "Portrait", Some("psk:FitApplicationBleedSizeToPageImageableSize")), Some(letter), false, true).is_err()+        );+        assert!(verify_ticket(&validated(letter, "Portrait", Some("ns0000:None")), Some(letter), false, true).is_ok(), "the IPP class driver's None");+        assert!(verify_ticket(&validated(letter, "Portrait", None), Some(letter), false, true).is_ok(), "a driver without scaling");+        // A driver with no PageScaling feature (Zebra's ZDesigner) answers CustomSquare when asked+        // for None: not asked, its answer is not checked; a driver that offers the feature is held to it.+        let custom = validated(letter, "Portrait", Some("psk:CustomSquare"));+        assert_eq!(verify_ticket(&custom, Some(letter), false, false), Ok(letter), "not offered: not refused");+        assert!(verify_ticket(&custom, Some(letter), false, true).is_err(), "offered: it would scale");+        assert!(verify_ticket(&validated((0, 279_400), "Portrait", None), None, false, true).is_err(), "a roll has no length");+        assert!(verify_ticket("<not xml", Some(letter), false, true).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());+        assert!(verify_job_options(&xml, &job, 3, true).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(from, to), &job, 3, true).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());+        assert!(verify_job_options(&xml.replace("psk:Collated", "vendor:Collated"), &job, 3, true).is_err(), "namespace matters");+        assert!(verify_job_options(&xml.replace("psk:Monochrome", "psk:Grayscale"), &job, 3, true).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}");+            assert!(verify_job_options(&dropped, &job, 3, true).is_err(), "{feature}");             if feature == "DocumentCollate" {-                assert!(verify_job_options(&dropped, &job, 1).is_ok(), "one page has no collating order");+                assert!(verify_job_options(&dropped, &job, 1, true).is_ok(), "one page has no collating order");+            }+            if feature == "JobDuplexAllDocumentsContiguously" {+                assert!(verify_job_options(&dropped, &job, 3, false).is_err(), "two-sided asked of a printer without it");             }         }         let default = Job::default();         let xml = ticket_xml(&default, false);-        assert!(verify_job_options(&xml, &default, 3).is_ok());+        assert!(verify_job_options(&xml, &default, 3, true).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());+        assert!(verify_job_options(&ticket_xml(&simplex, false), &simplex, 3, true).is_ok());+        assert!(verify_job_options(&xml, &simplex, 3, true).is_err(), "explicit simplex must be confirmed");+        // A driver with no two-sided feature (Zebra's label drivers) drops the one-sided request:+        // it can only print one side, so nothing is lost and nothing is refused.+        assert!(verify_job_options(&xml, &simplex, 3, false).is_ok(), "one-sided on a printer without two-sided");+        assert!(verify_job_options("<not xml", &default, 1, true).is_err());+    }++    #[test]+    fn a_sheet_is_turned_only_when_its_shape_differs_from_the_papers() {+        let letter = Some((215_900, 279_400));+        let label = Some((69_850, 31_750)); // 2.75 × 1.25 in, wider than tall as the printer feeds it+        assert!(turned(true, letter), "a wide sheet on upright paper is turned");+        assert!(!turned(false, letter));+        assert!(!turned(true, label), "a wide sheet on a wide label stock lies as it is");+        assert!(turned(false, label), "an upright sheet on a wide label stock is turned");+        assert!(turned(true, None) && !turned(false, None), "no paper known: the sheet's shape, as on upright paper");+        assert!(!turned(false, Some((100_000, 100_000))) && turned(true, Some((100_000, 100_000))), "a square paper is upright");+        // A MUNBYN ITPP941's native 60 × 40 mm form (storytold/pdfcraft#2673): a wide sheet is not+        // turned a second time, and its upright 102.1 × 152.15 mm form turns a wide sheet as usual.+        let munbyn = Some((60_000, 40_000));+        assert!(!turned(true, munbyn) && turned(false, munbyn));+        let upright_label = Some((102_100, 152_150));+        assert!(turned(true, upright_label) && !turned(false, upright_label));+    }++    #[test]+    fn a_label_printer_is_monochrome_only_on_label_sized_paper() {+        let label = Some((38_100, 25_400)); // 1.5 × 1 in+        let four_by_six = Some((101_600, 152_400));+        let letter = Some((215_900, 279_400));+        assert!(label_shaped(&["Monochrome"], label) && label_shaped(&["Monochrome"], four_by_six));+        assert!(!label_shaped(&["Monochrome"], letter), "a fax or a monochrome laser");+        assert!(!label_shaped(&["Monochrome", "Color"], label), "a colour label printer keeps its driver's way");+        assert!(!label_shaped(&[], label) && !label_shaped(&["Monochrome"], None) && !label_shaped(&["Monochrome"], Some((0, 25_400))));+        assert_eq!(media_size(&validated((38_100, 25_400), "psk:Portrait", None)), Some((38_100, 25_400)));+        assert_eq!(media_size("not xml"), None);+    }++    #[test]+    fn the_paper_a_devmode_prints_on_is_read_from_its_public_fields() {+        // A DEVMODEW: dmFields at 72, dmOrientation at 76, dmPaperSize 78, dmPaperLength 80, dmPaperWidth 82 (tenths of mm).+        let mut dm = vec![0u8; 220];+        let put16 = |dm: &mut Vec<u8>, at: usize, v: i16| dm[at..at + 2].copy_from_slice(&v.to_le_bytes());+        dm[72..76].copy_from_slice(&(0x1u32 | 0x2 | 0x4 | 0x8).to_le_bytes());+        put16(&mut dm, 76, 1);+        put16(&mut dm, 78, 256);+        put16(&mut dm, 80, 318); // 1.25 in+        put16(&mut dm, 82, 699); // 2.75 in+        assert_eq!(devmode_paper(&dm), Some(((69_900, 31_800), false)));+        put16(&mut dm, 76, 2);+        assert_eq!(devmode_paper(&dm), Some(((69_900, 31_800), true)), "landscape");+        dm[72..76].copy_from_slice(&(0x2u32).to_le_bytes());+        assert_eq!(devmode_paper(&dm), None, "no width and length fields set");+        dm[72..76].copy_from_slice(&(0x4u32 | 0x8).to_le_bytes());+        put16(&mut dm, 80, 0);+        assert_eq!(devmode_paper(&dm), None, "a roll has no length");+        assert_eq!(devmode_paper(&dm[..60]), None, "too short");+        assert_eq!(devmode_paper(&[]), None);     }      #[test]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@@ -18,7 +18,7 @@ use winprint::ticket::{FeatureOptionPack as _, PageMediaSize, PrintCapabilities,  use crate::sheets::{self, Fit, SAME}; use crate::ticket::{self, Paper};-use crate::{Error, Job, Printer, Scaling};+use crate::{Error, Job, Printer, PrinterPaper, Scaling};  fn spool_error(e: impl std::fmt::Display) -> Error {     Error::Spool(e.to_string())@@ -40,11 +40,105 @@ pub(crate) fn printers() -> Vec<Printer> { /// option for the feature the job's ticket sets. `None` when the capabilities can't be read. A /// 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 capabilities = capabilities_for(device, user_ticket(device).as_ref()).ok()?;     let duplex = OwnedName::qualified("JobDuplexAllDocumentsContiguously", NS_PSK, Some("psk"));     Some(capabilities.options_for_feature(duplex).any(|o| o.name.as_ref().is_some_and(|n| n.local_name.starts_with("TwoSided")))) } +/// [`crate::printer_papers`]: the driver's page media sizes as it has them, keyed by their+/// Print Schema or vendor name and named by their display name, the driver's current default+/// marked. Roll media (a side of 0) and sizes beyond PDF's 200-inch page limit are left out, as+/// are repeated keywords.+pub(crate) fn printer_papers(printer: &str) -> Vec<PrinterPaper> {+    const MAX_SIDE: f64 = 14_400.0;+    let Ok(devices) = PrinterDevice::all() else { return Vec::new() };+    let Some(device) = devices.iter().find(|d| d.name() == printer) else { return Vec::new() };+    let base = user_ticket(device);+    let Ok(capabilities) = capabilities_for(device, base.as_ref()) else { return Vec::new() };+    let default_key = default_media_keyword(device, base.as_ref(), &capabilities);+    let mut out: Vec<PrinterPaper> = Vec::new();+    for media in capabilities.page_media_sizes() {+        if out.len() >= crate::MAX_PRINTER_PAPERS {+            break;+        }+        let Some(key) = media.option().name.as_ref().map(|n| n.local_name.clone()).filter(|k| !k.is_empty()) else { continue };+        let size = media.size();+        let (w, h) = (ticket::points(size.width_in_micron()), ticket::points(size.height_in_micron()));+        if size.is_roll() || !(1.0..=MAX_SIDE).contains(&w) || !(1.0..=MAX_SIDE).contains(&h) || out.iter().any(|p| p.key == key) {+            continue;+        }+        let label = media.display_name().filter(|l| !l.trim().is_empty()).map_or_else(|| key.clone(), |l| l.trim().to_string());+        let default = default_key.as_deref() == Some(key.as_str());+        out.push(PrinterPaper { key, label, size: (w, h), default });+    }+    out+}++/// The signed-in user's own settings for the printer as a job ticket: what they set in its+/// Printing Preferences (`PrintTicket::user_default`). Windows' Print Ticket calls otherwise work+/// from the printer's Printing Defaults, which can hold another label stock, another darkness and+/// no cut (a Zebra ZD500 whose defaults said 4.09 × 3.00 in, darkness 10, while the user had set+/// 2.25 × 1.25 in, darkness 22); every ticket and capabilities request here starts from this one.+/// `None` when the driver won't say, and then the printer's defaults apply as before.+fn user_ticket(device: &PrinterDevice) -> Option<PrintTicket> {+    PrintTicket::user_default(device).ok()+}++/// The driver's capabilities for the user's settings, else for the printer's defaults.+fn capabilities_for(device: &PrinterDevice, base: Option<&PrintTicket>) -> Result<PrintCapabilities, Error> {+    match base {+        Some(base) => PrintCapabilities::fetch_for_ticket(device, base).or_else(|_| PrintCapabilities::fetch(device)),+        None => PrintCapabilities::fetch(device),+    }+    .map_err(spool_error)+}++/// [`crate::is_label_printer`]: the driver's PageOutputColor options and the paper of the user's+/// settings ([`ticket::label_shaped`]).+pub(crate) fn is_label_printer(printer: &str) -> bool {+    let Ok(devices) = PrinterDevice::all() else { return false };+    let Some(device) = devices.iter().find(|d| d.name() == printer) else { return false };+    let base = user_ticket(device);+    let Ok(capabilities) = capabilities_for(device, base.as_ref()) else { return false };+    let colour = OwnedName::qualified("PageOutputColor", NS_PSK, Some("psk"));+    let colours: Vec<String> = capabilities.options_for_feature(colour).filter_map(|o| o.name.as_ref().map(|n| n.local_name.clone())).collect();+    let colours: Vec<&str> = colours.iter().map(String::as_str).collect();+    let paper = base.as_ref().and_then(|t| std::str::from_utf8(t.get_xml()).ok()).and_then(ticket::media_size);+    ticket::label_shaped(&colours, paper)+}++/// The keyword of the driver's current default paper among `capabilities`' papers: the user's+/// settings' paper when the capabilities have it, else an empty ticket merged and validated, which+/// comes back complete at the printer's defaults (the stock a label printer is set up for, an+/// office printer's tray paper); the capabilities fall back to the printer's defaults when the+/// user's can't be read, and then so does the default. `None` when the driver won't say.+fn default_media_keyword(device: &PrinterDevice, base: Option<&PrintTicket>, capabilities: &PrintCapabilities) -> Option<String> {+    let offered = |key: &str| capabilities.page_media_sizes().any(|m| m.option().name.as_ref().is_some_and(|n| n.local_name == key));+    if let Some(key) = base.and_then(|t| std::str::from_utf8(t.get_xml()).ok()).and_then(ticket::media_keyword)+        && offered(&key)+    {+        return Some(key);+    }+    let mut builder = PrintTicketBuilder::new(device).ok()?;+    builder.merge(PrintTicket::from_xml(winprint::ticket::DEFAULT_PRINT_TICKET_XML)).ok()?;+    let validated = builder.build().ok()?;+    std::str::from_utf8(validated.get_xml()).ok().and_then(ticket::media_keyword)+}++/// What [`driver_ticket`] settled on.+struct DriverTicket {+    /// The ticket the job is printed with.+    ticket: PrintTicket,+    /// The driver's merged and validated ticket: what the driver kept of the job's options+    /// (copies, collation, two-sided, colour), held to them by `ticket::verify_job_options`+    /// whichever ticket is printed with.+    validated: PrintTicket,+    /// The paper, in micrometres.+    paper: (u32, u32),+    /// Whether the driver has a two-sided feature at all.+    duplex_offered: bool,+}+ /// The queue the job goes to: the one asked for, else the default. Never another one: a job /// for a queue that has gone must not come out of a different printer. fn select_device(devices: Vec<PrinterDevice>, requested: Option<&str>, default: Option<&str>) -> Result<PrinterDevice, Error> {@@ -62,8 +156,13 @@ fn select_device(devices: Vec<PrinterDevice>, requested: Option<&str>, default: /// its default paper is used. Returns the ticket and its paper in micrometres. Refused when the /// driver changed the paper, the orientation or the scaling: then nothing is sent, rather than a /// job at the wrong size.-fn driver_ticket(device: &PrinterDevice, job: &Job, sheet: (f64, f64), landscape: bool) -> Result<(PrintTicket, (u32, u32)), Error> {-    let capabilities = PrintCapabilities::fetch(device).map_err(spool_error)?;+fn driver_ticket(device: &PrinterDevice, job: &Job, sheet: (f64, f64), landscape: bool) -> Result<DriverTicket, Error> {+    let base = user_ticket(device);+    let capabilities = capabilities_for(device, base.as_ref())?;+    // Whether the driver has a two-sided feature at all: a label printer's doesn't, and its+    // answer to a one-sided request is then not judged (see `ticket::verify_job_options`).+    let duplex_offered =+        capabilities.options_for_feature(OwnedName::qualified("JobDuplexAllDocumentsContiguously", NS_PSK, Some("psk"))).next().is_some();     let medias: Vec<PageMediaSize> = capabilities.page_media_sizes().collect();     let papers: Vec<Paper<'_>> = medias         .iter()@@ -87,7 +186,19 @@ fn driver_ticket(device: &PrinterDevice, job: &Job, sheet: (f64, f64), landscape             )));         }     };-    let requested = PrintTicket::from_xml(ticket::ticket_xml(job, landscape));+    // Which way the sheet lies on the paper: turned when its shape differs from the paper's as the+    // driver feeds it. Office papers are upright, so a sheet wider than tall is turned; a label+    // stock defined across the feed (a Zebra's 2.75 × 1.25 in) is wider than tall itself, so a+    // sheet of that shape lies on it as it is and must not be turned, and an upright sheet must.+    // A sheet that matches none of the driver's papers (shrink to fit) is printed on the driver's+    // default paper, so the turn is judged against that one.+    let fed = wanted.or_else(|| {+        let key = default_media_keyword(device, base.as_ref(), &capabilities)?;+        let m = medias.iter().find(|m| m.option().name.as_ref().is_some_and(|n| n.local_name == key))?;+        Some((m.size().width_in_micron(), m.size().height_in_micron()))+    });+    let turned = ticket::turned(landscape, fed);+    let requested = PrintTicket::from_xml(ticket::ticket_xml(job, turned));     let mut document = PrintTicketDocument::parse_from_bytes(requested.get_xml()).map_err(spool_error)?;     if let Some(media) = media {         let media_ticket: PrintTicket = media.clone().into();@@ -96,17 +207,73 @@ fn driver_ticket(device: &PrinterDevice, job: &Job, sheet: (f64, f64), landscape         document.parameter_inits.extend(media_document.parameter_inits);     }     let scaling_name = OwnedName::qualified("PageScaling", NS_PSK, Some("psk"));-    if let Some(no_scaling) = capabilities.options_for_feature(scaling_name.clone()).find(|o| o.name.as_ref().is_some_and(|n| n.local_name == "None"))-    {-        document.features.retain(|f| !(f.name.local_name == "PageScaling" && f.name.namespace_ref() == Some(NS_PSK)));-        document.features.push(PrintFeature { name: scaling_name, properties: Vec::new(), options: vec![no_scaling.clone()], features: Vec::new() });+    let scaling_offered: Vec<_> = capabilities.options_for_feature(scaling_name.clone()).collect();+    let is_page_scaling = |f: &PrintFeature| f.name.local_name == "PageScaling" && f.name.namespace_ref() == Some(NS_PSK);+    if scaling_offered.is_empty() {+        // A driver without a PageScaling feature (Zebra's ZDesigner label drivers) is not asked+        // for one: asked for None, it answers CustomSquare, which the check below would refuse,+        // though it cannot scale by a feature it doesn't have.+        document.features.retain(|f| !is_page_scaling(f));+    } else if let Some(no_scaling) = scaling_offered.iter().find(|o| o.name.as_ref().is_some_and(|n| n.local_name == "None")) {+        document.features.retain(|f| !is_page_scaling(f));+        document.features.push(PrintFeature {+            name: scaling_name,+            properties: Vec::new(),+            options: vec![(*no_scaling).clone()],+            features: Vec::new(),+        });     }+    let requested_xml = PrintTicket::from(document).into_xml();+    // The job starts from the user's own settings (stock, darkness, cutter: the driver's private+    // features ride along) and the job's options are merged over them.     let mut builder = PrintTicketBuilder::new(device).map_err(spool_error)?;-    builder.merge(PrintTicket::from(document)).map_err(spool_error)?;+    if let Some(base) = &base {+        builder.merge(base.clone()).map_err(spool_error)?;+    }+    builder.merge(PrintTicket::from_xml(requested_xml.clone())).map_err(spool_error)?;     let validated = builder.build().map_err(spool_error)?;     let xml = std::str::from_utf8(validated.get_xml()).map_err(spool_error)?;-    let paper = ticket::verify_ticket(xml, wanted, landscape)?;-    Ok((validated, paper))+    // The paper the job comes out on is the one in the DEVMODE winprint prints with: the ticket+    // converted on the user's own settings and settled by the driver. The merged ticket is built+    // on the printer's defaults instead, and a driver whose one "User defined" stock is set in+    // the user's Printing Preferences but not in the printer's Printing Defaults (Zebra's) names+    // the defaults' size there while printing on the preferences'. So when the driver settles on+    // the paper asked for, the ticket asked for is the one printed with, and the merged ticket is+    // only held to the rest; otherwise the merged ticket is held to the paper as well. The ticket+    // asked for is laid over the user's settings, so their private features (a cutter, a+    // darkness, a finisher) are printed with too: the spooler merges a job's ticket onto the+    // printer's defaults, not the user's.+    let requested = PrintTicket::from_xml(requested_xml);+    let requested = match &base {+        Some(base) => overlay(base, &requested)?,+        None => requested,+    };+    let settled = requested.to_dev_mode(device).ok().and_then(|dev_mode| ticket::devmode_paper(&dev_mode));+    let same = |a: (u32, u32), b: (u32, u32)| a.0.abs_diff(b.0) <= ticket::PAPER_TOLERANCE && a.1.abs_diff(b.1) <= ticket::PAPER_TOLERANCE;+    if let (Some((size, settled_landscape)), Some(want)) = (settled, wanted)+        && (same(size, want) || same(size, (want.1, want.0)))+        && settled_landscape == turned+    {+        ticket::verify_ticket(xml, None, turned, !scaling_offered.is_empty())?;+        return Ok(DriverTicket { ticket: requested, validated, paper: size, duplex_offered });+    }+    let paper = ticket::verify_ticket(xml, wanted, turned, !scaling_offered.is_empty())?;+    Ok(DriverTicket { ticket: validated.clone(), validated, paper, duplex_offered })+}++/// `over` laid over `base`: each of `over`'s features, parameters and properties replaces+/// `base`'s of the same name, and the rest of `base` is kept.+fn overlay(base: &PrintTicket, over: &PrintTicket) -> Result<PrintTicket, Error> {+    let same = |a: &OwnedName, b: &OwnedName| a.local_name == b.local_name && a.namespace == b.namespace;+    let mut document = PrintTicketDocument::parse_from_bytes(base.get_xml()).map_err(spool_error)?;+    let top = PrintTicketDocument::parse_from_bytes(over.get_xml()).map_err(spool_error)?;+    document.features.retain(|f| !top.features.iter().any(|t| same(&t.name, &f.name)));+    document.features.extend(top.features);+    document.parameter_inits.retain(|p| !top.parameter_inits.iter().any(|t| same(&t.name, &p.name)));+    document.parameter_inits.extend(top.parameter_inits);+    document.properties.retain(|p| !top.properties.iter().any(|t| same(&t.name, &p.name)));+    document.properties.extend(top.properties);+    Ok(PrintTicket::from(document)) }  /// The widest unprintable margin of the ticket's paper, in points; 0 when the driver doesn't say.@@ -142,9 +309,9 @@ pub(crate) fn submit_with_progress(pdf: &[u8], job: &Job, progress: &mut dyn FnM     let read = &read;     // 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 DriverTicket { ticket, validated, paper, duplex_offered } = driver_ticket(&device, job, first, landscape)?;+        let xml = std::str::from_utf8(validated.get_xml()).map_err(spool_error)?;+        ticket::verify_job_options(xml, job, count, duplex_offered)?;         let (fit, paper) = match job.scaling {             Scaling::Exact => (Fit::Exact, first),             Scaling::ShrinkToFit => {@@ -198,8 +365,8 @@ mod tests {         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, ..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();+        let DriverTicket { ticket, validated, paper, duplex_offered } = driver_ticket(&device, &job, (612.0, 792.0), false).unwrap();+        ticket::verify_job_options(std::str::from_utf8(validated.get_xml()).unwrap(), &job, 1, duplex_offered).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") {@@ -211,8 +378,8 @@ mod tests {             ("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);+            let DriverTicket { validated, duplex_offered, .. } = driver_ticket(&device, &requested, (612.0, 792.0), false).unwrap();+            let retained = ticket::verify_job_options(std::str::from_utf8(validated.get_xml()).unwrap(), &requested, 3, duplex_offered);             eprintln!("{feature}={wanted}: {retained:?}");             let offered = capabilities                 .options_for_feature(OwnedName::qualified(feature, NS_PSK, Some("psk")))