collective

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

Print: Windows exact custom media sizes (#4004)

Print: Windows exact custom media sizes (#4004) ## What and why Fixes the Windows side of storytold/vectorcraft#4004: a 1050 × 480 mm artboard printed with a **1067 × 480 mm** custom paper on an **HP DesignJet Z5200ps V4** comes out 480 mm wide (the page's height), while VectorCraft's preview and print-ready PDF are correct. The in-process Windows backend used a named driver paper for exact-size jobs and refused when the driver had none. This driver has no 1067 × 480 form, but it does advertise `psk:CustomMediaSize` (width 78.994–1123.95 mm, height 139.996–91000 mm). The old PowerShell/GDI backend synthesized a portrait `PaperSize` and se

Status
open
Score
0.00
Adopters
0 other operators
Author
darrenstarr (imported from pull request #281, open)
Base
13d766e0746bc31b1bc55668bfa3fb009429b385
Size
+240 −12 in 3 files
Review
pending
Shared
2026-10-28
Id
2eb8fcd13155b6f4c82ebc0d

How it went

No other operator has reported using it yet.

Diff

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

diff --git a/crates/print/README.md b/crates/print/README.md--- a/crates/print/README.md+++ b/crates/print/README.md@@ -27,7 +27,10 @@ opens Windows printer preferences and submits jobs with copies, collation, duple   from storytold/pdfcraft#599 and #1132.   A job has one ticket, so one paper and orientation. Sheets of the other orientation are   turned back with `/Rotate`, so portrait and landscape sheets still print as one job. Exact-  size refuses sheets of different sizes and paper the driver lacks. Shrink-to-fit uses the+  size refuses sheets of different sizes and paper the driver lacks; when no named paper matches,+  it uses the driver's `psk:CustomMediaSize` option with the sheet's actual dimensions (for+  example a 1067 x 480 mm plotter sheet), refusing a size outside the driver's limits instead of+  cropping it. Shrink-to-fit uses the   driver's matching paper, else its default paper, and puts every sheet on it: a sheet that   doesn't fit inside the paper's widest hardware margin is shrunk, a smaller one keeps its   size, and both are centred. Both are done by appending an incremental update to the PDFdiff --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@@ -146,11 +146,10 @@ fn integer(node: roxmltree::Node<'_, '_>, tag: &str, name: &str) -> Option<u32> /// 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> {     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 root = document.root_element();+    let feature = |name: &str| root.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"))-        .filter(|(w, h)| *w > 0 && *h > 0)+    let size = resolved_media_size(root, media)         .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 {         let same = |a: (u32, u32), b: (u32, u32)| a.0.abs_diff(b.0) <= PAPER_TOLERANCE && a.1.abs_diff(b.1) <= PAPER_TOLERANCE;@@ -265,6 +264,59 @@ mod tests {         assert!(!ticket_xml(&Job { printer: Some("Office \"east\" <1>".into()), ..Job::default() }, false).contains("east"));     } +    /// A validated ticket for a driver custom media size, as the driver returns it: the option's+    /// dimensions are parameter references, resolved from the parameter initializers.+    fn custom_validated(paper: (u32, u32), orientation: &str) -> String {+        format!(+            r#"<psf:PrintTicket version="1" xmlns:psf="{NS_PSF}" xmlns:psk="{NS_PSK}" xmlns:media="{NS_PSK}" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">+<psf:ParameterInit name="media:PageMediaSizeMediaSizeWidth"><psf:Value xsi:type="xsd:integer">{}</psf:Value></psf:ParameterInit>+<psf:ParameterInit name="media:PageMediaSizeMediaSizeHeight"><psf:Value xsi:type="xsd:integer">{}</psf:Value></psf:ParameterInit>+<psf:Feature name="psk:PageMediaSize"><psf:Option name="psk:CustomMediaSize">+<psf:ScoredProperty name="psk:MediaSizeWidth"><psf:ParameterRef name="psk:PageMediaSizeMediaSizeWidth"/></psf:ScoredProperty>+<psf:ScoredProperty name="psk:MediaSizeHeight"><psf:ParameterRef name="psk:PageMediaSizeMediaSizeHeight"/></psf:ScoredProperty>+</psf:Option></psf:Feature>+<psf:Feature name="psk:PageOrientation"><psf:Option name="psk:{orientation}"/></psf:Feature>+</psf:PrintTicket>"#,+            paper.0, paper.1+        )+    }++    #[test]+    fn a_custom_media_ticket_resolves_its_parameters() {+        let custom = (1_067_000, 480_000);+        let xml = custom_validated(custom, "Portrait");+        assert_eq!(verify_ticket(&xml, Some(custom), false), Ok(custom));+        assert_eq!(verify_ticket(&xml, Some((custom.1, custom.0)), false), Ok(custom), "either way round");+        assert!(verify_ticket(&xml, Some((1_000_000, 480_000)), false).is_err(), "another size");+        assert!(verify_ticket(&xml, Some(custom), true).is_err(), "orientation");+    }++    #[test]+    fn custom_media_ranges_come_from_the_capabilities() {+        let caps = format!(+            r#"<psf:PrintCapabilities 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:CustomMediaSize">+<psf:ScoredProperty name="psk:MediaSizeWidth"><psf:ParameterRef name="psk:PageMediaSizeMediaSizeWidth"/></psf:ScoredProperty>+<psf:ScoredProperty name="psk:MediaSizeHeight"><psf:ParameterRef name="psk:PageMediaSizeMediaSizeHeight"/></psf:ScoredProperty>+</psf:Option></psf:Feature>+<psf:ParameterDef name="psk:PageMediaSizeMediaSizeWidth"><psf:Property name="psf:MinValue"><psf:Value xsi:type="xsd:integer">78994</psf:Value></psf:Property><psf:Property name="psf:MaxValue"><psf:Value xsi:type="xsd:integer">1123950</psf:Value></psf:Property></psf:ParameterDef>+<psf:ParameterDef name="psk:PageMediaSizeMediaSizeHeight"><psf:Property name="psf:MinValue"><psf:Value xsi:type="xsd:integer">139996</psf:Value></psf:Property><psf:Property name="psf:MaxValue"><psf:Value xsi:type="xsd:integer">91000008</psf:Value></psf:Property></psf:ParameterDef>+</psf:PrintCapabilities>"#+        );+        let custom = custom_media(&caps).expect("custom media");+        assert_eq!(custom, CustomMedia { min_width: 78_994, max_width: 1_123_950, min_height: 139_996, max_height: 91_000_008 });+        assert!(custom.allows(1_067_000, 480_000));+        assert!(!custom.allows(1_200_000, 480_000));+        assert!(!custom.allows(1_067_000, 100));+        assert_eq!(custom.fit(1_067_000, 480_000), Some((1_067_000, 480_000)));+        assert_eq!(custom.fit(1_200_000, 480_000), Some((480_000, 1_200_000)), "the other way round fits");+        assert_eq!(custom.fit(2_000_000, 2_000_000), None);+        assert!(+            custom_media("<psf:PrintCapabilities xmlns:psf=\"http://schemas.microsoft.com/windows/2003/08/printing/printschemaframework\"/>")+                .is_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>"#));@@ -405,3 +457,86 @@ mod tests {         assert_eq!(job_filename("Отчёт"), "Отчёт.pdf");     } }+/// The driver's custom-media support and the bounds its `psk:CustomMediaSize` width and height+/// parameters take, in micrometres. Only a driver that offers the option with both bounds can+/// take a size no named paper covers.+#[derive(Clone, Copy, Debug, PartialEq, Eq)]+pub(crate) struct CustomMedia {+    pub min_width: u32,+    pub max_width: u32,+    pub min_height: u32,+    pub max_height: u32,+}++impl CustomMedia {+    /// Whether a sheet of `width` � `height` micrometres is inside the driver's custom-media range.+    pub(crate) fn allows(self, width: u32, height: u32) -> bool {+        (self.min_width..=self.max_width).contains(&width) && (self.min_height..=self.max_height).contains(&height)+    }++    /// The sheet's dimensions in the order this driver can take, either way round; `None` when+    /// neither order is inside its ranges.+    pub(crate) fn fit(self, width: u32, height: u32) -> Option<(u32, u32)> {+        [(width, height), (height, width)].into_iter().find(|&(w, h)| self.allows(w, h))+    }+}++/// The driver's `psk:CustomMediaSize` option and the ranges of its width and height parameters,+/// from its PrintCapabilities XML. `None` when the driver offers no custom media, or gives no+/// bounds for a side.+pub(crate) fn custom_media(xml: &str) -> Option<CustomMedia> {+    let document = roxmltree::Document::parse(xml).ok()?;+    let root = document.root_element();+    let feature = root.children().find(|n| n.has_tag_name((NS_PSF, "Feature")) && named(*n, "PageMediaSize"))?;+    feature.children().find(|n| n.has_tag_name((NS_PSF, "Option")) && named(*n, "CustomMediaSize"))?;+    let range = |name: &str| {+        let def = root.descendants().find(|n| n.has_tag_name((NS_PSF, "ParameterDef")) && named_ns(*n, name, NS_PSK))?;+        let bound = |property: &str| {+            def.descendants()+                .find(|n| n.has_tag_name((NS_PSF, "Property")) && named_ns(*n, property, NS_PSF))+                .and_then(|n| n.descendants().find(|v| v.has_tag_name((NS_PSF, "Value"))))+                .and_then(|v| v.text())+                .and_then(|v| v.trim().parse::<u32>().ok())+        };+        Some((bound("MinValue")?, bound("MaxValue")?))+    };+    let (min_width, max_width) = range("PageMediaSizeMediaSizeWidth")?;+    let (min_height, max_height) = range("PageMediaSizeMediaSizeHeight")?;+    (min_width <= max_width && min_height <= max_height).then_some(CustomMedia { min_width, max_width, min_height, max_height })+}++fn named_ns(node: roxmltree::Node<'_, '_>, local: &str, namespace: &str) -> bool {+    node.attribute("name")+        .and_then(|v| v.split_once(':'))+        .is_some_and(|(prefix, name)| name == local && node.lookup_namespace_uri(Some(prefix)) == Some(namespace))+}++/// A `name` attribute's local part and the namespace URI its prefix resolves to in this node.+/// Prefixes are only XML spelling; the same namespace under two prefixes names the same thing.+fn qualified_name(node: roxmltree::Node<'_, '_>, attribute: &str) -> Option<(String, String)> {+    let value = node.attribute(attribute)?;+    let (prefix, local) = value.split_once(':').map_or((None, value), |(prefix, local)| (Some(prefix), local));+    let namespace = node.lookup_namespace_uri(prefix)?;+    Some((local.to_owned(), namespace.to_owned()))+}++/// The integer a scored property carries, directly or through its parameter initializer.+fn scored_integer(root: roxmltree::Node<'_, '_>, media: roxmltree::Node<'_, '_>, name: &str) -> Option<u32> {+    let scored = media.descendants().find(|n| n.has_tag_name((NS_PSF, "ScoredProperty")) && named(*n, name))?;+    if let Some(value) =+        scored.children().find(|n| n.has_tag_name((NS_PSF, "Value"))).and_then(|n| n.text()).and_then(|v| v.trim().parse::<u32>().ok())+    {+        return Some(value);+    }+    let reference = qualified_name(scored.children().find(|n| n.has_tag_name((NS_PSF, "ParameterRef")))?, "name")?;+    let init = root.descendants().find(|n| {+        n.has_tag_name((NS_PSF, "ParameterInit")) && qualified_name(*n, "name").is_some_and(|name| name.0 == reference.0 && name.1 == reference.1)+    })?;+    init.children().find(|n| n.has_tag_name((NS_PSF, "Value"))).and_then(|n| n.text()).and_then(|v| v.trim().parse::<u32>().ok())+}++fn resolved_media_size(root: roxmltree::Node<'_, '_>, media: roxmltree::Node<'_, '_>) -> Option<(u32, u32)> {+    let width = scored_integer(root, media, "MediaSizeWidth")?;+    let height = scored_integer(root, media, "MediaSizeHeight")?;+    (width > 0 && height > 0).then_some((width, height))+}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,7 @@ 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_PSK, OwnedName, PrintFeature, PrintTicketDocument, PropertyValue, reader::ParsableXmlDocument as _}; use winprint::ticket::{FeatureOptionPack as _, PageMediaSize, PrintCapabilities, PrintTicket, PrintTicketBuilder};  use crate::sheets::{self, Fit, SAME};@@ -64,6 +64,9 @@ fn select_device(devices: Vec<PrinterDevice>, requested: Option<&str>, default: /// 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 capabilities_xml = PrintCapabilities::fetch_xml(device).map_err(spool_error)?;+    let capabilities_xml = std::str::from_utf8(&capabilities_xml).map_err(spool_error)?;+    let custom = ticket::custom_media(capabilities_xml);     let medias: Vec<PageMediaSize> = capabilities.page_media_sizes().collect();     let papers: Vec<Paper<'_>> = medias         .iter()@@ -75,16 +78,25 @@ fn driver_ticket(device: &PrinterDevice, job: &Job, sheet: (f64, f64), landscape         })         .collect();     let media = ticket::matching_paper(&papers, sheet.0, sheet.1).and_then(|i| medias.get(i));+    let mut custom_size = None;     let wanted = match (media, job.scaling) {         (Some(m), _) => Some((m.size().width_in_micron(), m.size().height_in_micron())),         (None, Scaling::ShrinkToFit) => 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 width = ticket::micrometres(sheet.0).ok_or_else(|| spool_error("the PDF sheet size is not usable"))?;+            let height = ticket::micrometres(sheet.1).ok_or_else(|| spool_error("the PDF sheet size is not usable"))?;+            let Some((width, height)) = custom.and_then(|range| range.fit(width, height)) else {+                return Err(spool_error(format!(+                    "{} has no {:.2} x {:.2} inch paper and no matching custom media size; choose a paper size it has, or add this one in its driver",+                    device.name(),+                    sheet.0 / 72.0,+                    sheet.1 / 72.0+                )));+            };+            // A wide custom sheet takes Landscape, as a wide sheet on a named portrait paper+            // does; the driver normalises the custom form to its own portrait dimensions.+            custom_size = Some((width, height));+            Some((width, height))         }     };     let requested = PrintTicket::from_xml(ticket::ticket_xml(job, landscape));@@ -94,6 +106,45 @@ fn driver_ticket(device: &PrinterDevice, job: &Job, sheet: (f64, f64), landscape         let media_document = PrintTicketDocument::parse_from_bytes(media_ticket.get_xml()).map_err(spool_error)?;         document.features.extend(media_document.features);         document.parameter_inits.extend(media_document.parameter_inits);+    } else if let Some((width, height)) = custom_size {+        let Some(mut custom) = medias+            .iter()+            .find(|m| m.option().name.as_ref().is_some_and(|n| n.namespace_ref() == Some(NS_PSK) && n.local_name == "CustomMediaSize"))+            .cloned()+        else {+            return Err(spool_error("the printer offers a custom media size but does not list it; nothing was sent"));+        };+        let (mut width_set, mut height_set) = (false, false);+        for parameter in custom.parameters_mut() {+            if parameter.name.namespace_ref() == Some(NS_PSK) && parameter.name.local_name == "PageMediaSizeMediaSizeWidth" {+                parameter.value = PropertyValue::Integer(width as i32);+                width_set = true;+            } else if parameter.name.namespace_ref() == Some(NS_PSK) && parameter.name.local_name == "PageMediaSizeMediaSizeHeight" {+                parameter.value = PropertyValue::Integer(height as i32);+                height_set = true;+            }+        }+        if !(width_set && height_set) {+            return Err(spool_error("the printer does not report the custom size parameters; nothing was sent"));+        }+        // The HP PCL3 driver reads the custom media dimensions from the option's direct scored+        // properties; with the capability's parameter references it emits PAPERLENGTH=width+        // (a square page). Put the two integers in the option itself.+        for property in &mut custom.option_mut().scored_properties {+            let Some(name) = property.name.as_ref() else { continue };+            let value = match name.local_name.as_str() {+                "MediaSizeWidth" => width,+                "MediaSizeHeight" => height,+                _ => continue,+            };+            property.parameter_ref = None;+            property.value = Some(PropertyValue::Integer(value as i32));+        }+        custom.parameters_mut().clear();+        let media_ticket: PrintTicket = custom.into();+        let media_document = PrintTicketDocument::parse_from_bytes(media_ticket.get_xml()).map_err(spool_error)?;+        document.features.extend(media_document.features);+        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"))@@ -101,6 +152,21 @@ fn driver_ticket(device: &PrinterDevice, job: &Job, sheet: (f64, f64), landscape         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() });     }+    // The HP PCL driver inherits PageAutorotate from the queue, so with a user's fixed+    // "rotate 0" setting it emits a square paper length (PJL PAPERLENGTH = PAPERWIDTH) and the+    // printer cuts the page at its width. A named paper or the driver's PrintTicket preview+    // still looks right. Let the printer manage the roll orientation, as Affinity does.+    if let Some(autorotate) = capabilities.document.features.iter().find(|f| f.name.local_name == "PageAutorotate")+        && let Some(option) = autorotate.options.iter().find(|o| o.name.as_ref().is_some_and(|n| n.local_name == "Printer" || n.local_name == "On"))+    {+        document.features.retain(|f| f.name.local_name != "PageAutorotate");+        document.features.push(PrintFeature {+            name: autorotate.name.clone(),+            properties: Vec::new(),+            options: vec![option.clone()],+            features: Vec::new(),+        });+    }     let mut builder = PrintTicketBuilder::new(device).map_err(spool_error)?;     builder.merge(PrintTicket::from(document)).map_err(spool_error)?;     let validated = builder.build().map_err(spool_error)?;@@ -223,6 +289,30 @@ mod tests {         }     } +    /// A real driver's custom media: a 1067 x 480 mm sheet, no named paper of that size. The+    /// driver must accept the custom PrintTicket and keep the requested paper; sends no job.+    /// `CRAFT_PRINT_TEST_PRINTER` names the queue; `CRAFT_PRINT_TEST_TICKET_OUTPUT` keeps the+    /// validated ticket.+    #[test]+    #[ignore = "needs CRAFT_PRINT_TEST_PRINTER naming an installed printer; sends no job"]+    fn an_installed_driver_validates_a_custom_media_ticket() {+        let name = std::env::var("CRAFT_PRINT_TEST_PRINTER").unwrap();+        let device = select_device(PrinterDevice::all().unwrap(), Some(&name), None).unwrap();+        let sheet = (3024.566929133858, 1360.6299212598426);+        let (ticket, paper) = match driver_ticket(&device, &Job::default(), sheet, true) {+            Ok(value) => value,+            Err(e) => panic!("{e}"),+        };+        let ticket_xml = std::str::from_utf8(ticket.get_xml()).unwrap();+        assert!(ticket_xml.contains("PageAutorotate"), "missing PageAutorotate: {ticket_xml}");+        assert!(ticket_xml.contains("Printer"), "missing Printer autorotate: {ticket_xml}");+        let expected = (1_067_000, 480_000);+        assert!(paper == expected || paper == (expected.1, expected.0), "driver paper {paper:?}");+        if let Ok(path) = std::env::var("CRAFT_PRINT_TEST_TICKET_OUTPUT") {+            std::fs::write(path, ticket.get_xml()).unwrap();+        }+    }+     /// A real job, for checking a printer by hand (paper, or a paused queue's spool file):     /// `CRAFT_PRINT_TEST_FILE` printed on `CRAFT_PRINT_TEST_PRINTER`, shrink-to-fit when     /// `CRAFT_PRINT_TEST_SCALING=shrink`, two-sided on the long edge when