collective

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

feat(print): Windows prints a sheet the driver doesn't list on its custom paper

feat(print): Windows prints a sheet the driver doesn't list on its custom paper ## What this changes On Windows, a job at exact size whose sheet the driver doesn't list was refused: "*printer* has no 3.94 × 5.91 inch paper; choose a paper size it has, or add this one in its driver". An app that offers a custom paper size (or prints a PDF page of a non-standard size at 100%) couldn't print it, even on drivers that accept custom paper. - **The driver's custom paper is used when it has one.** That is the Print Schema's `psk:CustomMediaSize` (`PageMediaSizeMediaSizeWidth`/`Height` parameters), or `psk:PSCustomMediaSize` (`PageMediaSizePSWidth`/`PSHeight`), which PostScript

Status
open
Score
0.00
Adopters
0 other operators
Author
bulgariamitko (imported from pull request #161, open)
Base
8f5a2831614e632f91ad367f7af2a6e5b61b9529
Size
+125 −16 in 2 files
Review
pending
Shared
2026-10-28
Id
60437fb7e53b7329676ab29f

How it went

No other operator has reported using it yet.

Diff

raw · crates/print/src/ticket.rs crates/print/src/windows.rs

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@@ -140,6 +140,17 @@ fn integer(node: roxmltree::Node<'_, '_>, tag: &str, name: &str) -> Option<u32>         .and_then(|v| v.trim().parse::<u32>().ok()) } +/// A paper's width or height, the first of `names` it has: its value, or for a custom paper the+/// value of the parameter it refers to.+fn dimension(root: roxmltree::Node<'_, '_>, media: roxmltree::Node<'_, '_>, names: &[&str]) -> Option<u32> {+    let property = media.descendants().find(|n| n.has_tag_name((NS_PSF, "ScoredProperty")) && names.iter().any(|name| named(*n, name)))?;+    let parameter = property.children().find(|n| n.has_tag_name((NS_PSF, "ParameterRef")));+    match parameter.and_then(|p| p.attribute("name")).and_then(|v| v.split_once(':')) {+        Some((_, name)) => integer(root, "ParameterInit", name),+        None => property.children().find(|v| v.has_tag_name((NS_PSF, "Value"))).and_then(|n| n.text()).and_then(|v| v.trim().parse::<u32>().ok()),+    }+}+ /// Check the driver's validated ticket: it kept the orientation, added no scaling and has a /// 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:@@ -148,8 +159,9 @@ pub(crate) fn verify_ticket(xml: &str, paper: Option<(u32, u32)>, landscape: boo     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"))?;-    let size = integer(media, "ScoredProperty", "MediaSizeWidth")-        .zip(integer(media, "ScoredProperty", "MediaSizeHeight"))+    let root = document.root_element();+    let size = dimension(root, media, &["MediaSizeWidth", "PSWidth"])+        .zip(dimension(root, media, &["MediaSizeHeight", "PSHeight"]))         .filter(|(w, h)| *w > 0 && *h > 0)         .ok_or_else(|| spool_error("the printer's driver gave a paper without a size (a roll?); nothing was sent"))?;     if let Some((w, h)) = paper {@@ -281,6 +293,32 @@ mod tests {         )     } +    /// A validated ticket with a custom paper whose size is in parameters: the Print Schema's+    /// own (`MediaSize…`) or a PostScript driver's (`PS…`).+    fn custom(option: &str, prefix: &str, paper: (u32, u32)) -> String {+        format!(+            r#"<psf:PrintTicket version="1" xmlns:psf="{NS_PSF}" xmlns:psk="{NS_PSK}" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">+<psf:Feature name="psk:PageMediaSize"><psf:Option name="psk:{option}">+<psf:ScoredProperty name="psk:{prefix}Width"><psf:ParameterRef name="psk:PageMediaSize{prefix}Width"/></psf:ScoredProperty>+<psf:ScoredProperty name="psk:{prefix}Height"><psf:ParameterRef name="psk:PageMediaSize{prefix}Height"/></psf:ScoredProperty>+</psf:Option></psf:Feature>+<psf:Feature name="psk:PageOrientation"><psf:Option name="psk:Portrait"/></psf:Feature>+<psf:ParameterInit name="psk:PageMediaSize{prefix}Width"><psf:Value xsi:type="xsd:integer">{}</psf:Value></psf:ParameterInit>+<psf:ParameterInit name="psk:PageMediaSize{prefix}Height"><psf:Value xsi:type="xsd:integer">{}</psf:Value></psf:ParameterInit>+</psf:PrintTicket>"#,+            paper.0, paper.1+        )+    }++    #[test]+    fn a_custom_paper_is_read_from_its_parameters() {+        let card = (100_000, 150_000);+        assert_eq!(verify_ticket(&custom("CustomMediaSize", "MediaSize", card), Some(card), false), Ok(card));+        assert_eq!(verify_ticket(&custom("PSCustomMediaSize", "PS", card), Some(card), false), Ok(card));+        assert!(verify_ticket(&custom("PSCustomMediaSize", "PS", (210_000, 297_000)), Some(card), false).is_err(), "the driver changed it");+        assert!(verify_ticket(&custom("PSCustomMediaSize", "PS", (0, 150_000)), None, false).is_err());+    }+     #[test]     fn a_driver_that_changes_the_sheet_is_refused() {         let letter = (215_900, 279_400);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@@ -13,7 +13,9 @@ use std::io::Write as _;  use winprint::printer::{PrinterDevice, WinPdfPrinter};-use winprint::ticket::document::{NS_PSK, OwnedName, PrintFeature, PrintTicketDocument, reader::ParsableXmlDocument as _};+use winprint::ticket::document::{+    NS_PSF, NS_PSK, OwnedName, ParameterInit, PrintFeature, PrintTicketDocument, PropertyValue, WithProperties as _, reader::ParsableXmlDocument as _,+}; use winprint::ticket::{FeatureOptionPack as _, PageMediaSize, PrintCapabilities, PrintTicket, PrintTicketBuilder};  use crate::sheets::{self, Fit, SAME};@@ -55,16 +57,65 @@ fn select_device(devices: Vec<PrinterDevice>, requested: Option<&str>, default:         .ok_or_else(|| spool_error(format!("the printer is not available: {name} (reopen Print to refresh the list)"))) } +/// The custom paper options of the Print Schema, each with its width and height parameters: the+/// standard one, and the one PostScript drivers offer instead.+const CUSTOM_PAPERS: [(&str, &str, &str); 2] = [+    ("CustomMediaSize", "PageMediaSizeMediaSizeWidth", "PageMediaSizeMediaSizeHeight"),+    ("PSCustomMediaSize", "PageMediaSizePSWidth", "PageMediaSizePSHeight"),+];++fn is_custom_paper(media: &PageMediaSize) -> bool {+    media.option().name.as_ref().is_some_and(|n| n.namespace_ref() == Some(NS_PSK) && CUSTOM_PAPERS.iter().any(|(o, _, _)| n.local_name == *o))+}++/// A custom paper the driver offers, or the range it takes, in micrometres.+enum CustomPaper {+    Sized(PageMediaSize),+    OutOfRange { min: (u32, u32), max: (u32, u32) },+}++/// The driver's custom paper option for a sheet of `width` × `height` micrometres (portrait)+/// that it doesn't list, with the width and height parameters set to the sheet's. `None` when it+/// offers no custom paper with the limits it takes.+fn custom_paper(capabilities: &PrintCapabilities, width: u32, height: u32) -> Option<CustomPaper> {+    let limit = |def: &winprint::ticket::document::ParameterDef, name: &str| {+        def.get_property(name, Some(NS_PSF)).and_then(|p| p.value.as_ref()).and_then(PropertyValue::integer).and_then(|v| u32::try_from(v).ok())+    };+    let range = |name: &str| {+        let def = capabilities.document.parameter_defs.iter().find(|d| d.name.local_name == name && d.name.namespace_ref() == Some(NS_PSK))?;+        Some((limit(def, "MinValue")?, limit(def, "MaxValue")?))+    };+    CUSTOM_PAPERS.iter().find_map(|&(option, width_name, height_name)| {+        let mut media = capabilities+            .page_media_sizes()+            .find(|m| m.option().name.as_ref().is_some_and(|n| n.local_name == option && n.namespace_ref() == Some(NS_PSK)))?;+        let (widths, heights) = (range(width_name)?, range(height_name)?);+        if !(widths.0..=widths.1).contains(&width) || !(heights.0..=heights.1).contains(&height) {+            return Some(CustomPaper::OutOfRange { min: (widths.0, heights.0), max: (widths.1, heights.1) });+        }+        for (name, value) in [(width_name, width), (height_name, height)] {+            let value = PropertyValue::Integer(i32::try_from(value).ok()?);+            let parameters = media.parameters_mut();+            match parameters.iter_mut().find(|p| p.name.local_name == name && p.name.namespace_ref() == Some(NS_PSK)) {+                Some(p) => p.value = value,+                None => parameters.push(ParameterInit { name: OwnedName::qualified(name, NS_PSK, Some("psk")), value }),+            }+        }+        Some(CustomPaper::Sized(media))+    })+}+ /// The ticket the driver accepted: [`ticket::ticket_xml`] with the driver's own paper option for /// `sheet` (its exact option, so a vendor's sizes survive; a ticket with dimensions only can come /// back as Letter) and its own "no scaling" option, merged and validated against the printer's-/// capabilities. For exact size the driver must have that paper; for shrink-to-fit without one,-/// its default paper is used. Returns the ticket and its paper in micrometres. Refused when the+/// capabilities. For exact size the driver must have that paper, or take it as a custom paper;+/// for shrink-to-fit without one, 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)?;-    let medias: Vec<PageMediaSize> = capabilities.page_media_sizes().collect();+    // Custom papers carry a placeholder size until one is set, so they never match a sheet.+    let medias: Vec<PageMediaSize> = capabilities.page_media_sizes().filter(|m| !is_custom_paper(m)).collect();     let papers: Vec<Paper<'_>> = medias         .iter()         .map(|m| Paper {@@ -74,17 +125,37 @@ fn driver_ticket(device: &PrinterDevice, job: &Job, sheet: (f64, f64), landscape             keyword: m.option().name.as_ref().is_some_and(|n| n.namespace_ref() == Some(NS_PSK)),         })         .collect();-    let media = ticket::matching_paper(&papers, sheet.0, sheet.1).and_then(|i| medias.get(i));-    let wanted = match (media, job.scaling) {-        (Some(m), _) => Some((m.size().width_in_micron(), m.size().height_in_micron())),-        (None, Scaling::ShrinkToFit) => None,+    let listed = ticket::matching_paper(&papers, sheet.0, sheet.1).and_then(|i| medias.get(i));+    let (media, wanted) = match (listed, job.scaling) {+        (Some(m), _) => (Some(m.clone()), Some((m.size().width_in_micron(), m.size().height_in_micron()))),+        (None, Scaling::ShrinkToFit) => (None, None),         (None, Scaling::Exact) => {-            return Err(spool_error(format!(-                "{} has no {:.2} × {:.2} inch paper; choose a paper size it has, or add this one in its driver",-                device.name(),-                sheet.0 / 72.0,-                sheet.1 / 72.0-            )));+            let (short, long) = (sheet.0.min(sheet.1), sheet.0.max(sheet.1));+            let size = ticket::micrometres(short).zip(ticket::micrometres(long));+            match size.and_then(|(w, h)| custom_paper(&capabilities, w, h).map(|c| (c, (w, h)))) {+                Some((CustomPaper::Sized(m), size)) => (Some(m), Some(size)),+                Some((CustomPaper::OutOfRange { min, max }, _)) => {+                    let mm = |v: u32| f64::from(v) / 1000.0;+                    return Err(spool_error(format!(+                        "{} takes custom paper from {:.0} × {:.0} mm to {:.0} × {:.0} mm, not {:.0} × {:.0} mm; choose a paper size it has",+                        device.name(),+                        mm(min.0),+                        mm(min.1),+                        mm(max.0),+                        mm(max.1),+                        short * 25.4 / 72.0,+                        long * 25.4 / 72.0+                    )));+                }+                None => {+                    return Err(spool_error(format!(+                        "{} has no {:.2} × {:.2} inch paper; choose a paper size it has, or add this one in its driver",+                        device.name(),+                        sheet.0 / 72.0,+                        sheet.1 / 72.0+                    )));+                }+            }         }     };     let requested = PrintTicket::from_xml(ticket::ticket_xml(job, landscape));