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
@@ -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 PDF@@ -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))+}@@ -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