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
@@ -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",@@ -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.@@ -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. @@ -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); @@ -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)]@@ -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]@@ -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")))