diff --git a/crates/print/src/ticket.rs b/crates/print/src/ticket.rs index 55a9c50ae..8e1171f71 100644 --- a/crates/print/src/ticket.rs +++ b/crates/print/src/ticket.rs @@ -222,7 +222,10 @@ pub(crate) struct Paper<'a> { /// An entry the driver keeps only one side of (the other reads zero, a legacy host-based driver's /// DEVMODE metric, storytold/craft-libs#7230) is a candidate of last resort (INV-4): only when no /// full entry matched, only a Print Schema keyword entry, and only while its known side agrees -/// with the sheet, either way round. +/// with the sheet, either way round. Several such entries that all agree (a Letter and a Legal +/// option share a width) are told apart by name, as full candidates are: the one named like the +/// requested standard sheet wins, and when that leaves more than one, or none, nothing is chosen — +/// a guess here would lay the sheets out for the wrong paper. pub(crate) fn matching_paper(papers: &[Paper<'_>], width: f64, height: f64) -> Option { let (w, h) = (micrometres(width.min(height))?, micrometres(width.max(height))?); let names = paper_names(width, height); @@ -240,9 +243,23 @@ pub(crate) fn matching_paper(papers: &[Paper<'_>], width: f64, height: f64) -> O return Some(index); } let agrees = |side: u32| side.abs_diff(w) <= PAPER_TOLERANCE || side.abs_diff(h) <= PAPER_TOLERANCE; - papers + let partials: Vec = papers .iter() - .position(|p| p.keyword && (p.width == 0) != (p.height == 0) && (p.width == 0 || agrees(p.width)) && (p.height == 0 || agrees(p.height))) + .enumerate() + .filter(|(_, p)| p.keyword && (p.width == 0) != (p.height == 0) && (p.width == 0 || agrees(p.width)) && (p.height == 0 || agrees(p.height))) + .map(|(i, _)| i) + .collect(); + match partials.as_slice() { + [only] => Some(*only), + // Ambiguous by agreement alone: a partial named like the requested standard sheet wins. + _ => { + let named: Vec = partials.iter().copied().filter(|i| names.iter().any(|n| names_paper(papers[*i].name, n))).collect(); + match named.as_slice() { + [only] => Some(*only), + _ => None, + } + } + } } fn named(node: roxmltree::Node<'_, '_>, local: &str) -> bool { @@ -285,12 +302,32 @@ pub(crate) fn two_sided(xml: &str) -> Option { ) } -/// The integer value of the Print Schema element under `node` with this keyword name. -fn integer(node: roxmltree::Node<'_, '_>, tag: &str, name: &str) -> Option { +/// The `Value` of the Print Schema element `element`: its own, or one a `ParameterRef` names +/// through the `ParameterInit` under the document's root that sets it (a driver may keep a paper +/// side or a resolution this way). `None` when the element has neither, or names a parameter the +/// ticket does not set. +fn resolved_value<'a>(document: &'a roxmltree::Document<'a>, element: roxmltree::Node<'a, 'a>) -> Option> { + let value = element.children().find(|v| v.has_tag_name((NS_PSF, "Value")) || v.has_tag_name((NS_PSF, "ParameterRef")))?; + if value.has_tag_name((NS_PSF, "Value")) { + return Some(value); + } + let parameter = expanded(value, value.attribute("name")?); + document + .root_element() + .children() + .filter(|p| p.has_tag_name((NS_PSF, "ParameterInit"))) + .find(|p| p.attribute("name").is_some_and(|name| expanded(*p, name) == parameter))? + .children() + .find(|v| v.has_tag_name((NS_PSF, "Value"))) +} + +/// The integer value of the Print Schema element under `node` with this keyword name, direct or +/// through a `ParameterRef` the ticket resolves ([`resolved_value`]). +fn integer(document: &roxmltree::Document<'_>, node: roxmltree::Node<'_, '_>, tag: &str, name: &str) -> Option { node.descendants() .find(|n| n.has_tag_name((NS_PSF, tag)) && named(*n, name)) - .and_then(|n| n.children().find(|v| v.has_tag_name((NS_PSF, "Value")))) - .and_then(|n| n.text()) + .and_then(|element| resolved_value(document, element)) + .and_then(|value| value.text()) .and_then(|v| v.trim().parse::().ok()) } @@ -310,8 +347,8 @@ pub(crate) fn verify_ticket(xml: &str, paper: Option<(u32, u32)>, landscape: boo let document = crate::xml::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 width = integer(media, "ScoredProperty", "MediaSizeWidth").filter(|w| *w > 0); - let height = integer(media, "ScoredProperty", "MediaSizeHeight").filter(|h| *h > 0); + let width = integer(&document, media, "ScoredProperty", "MediaSizeWidth").filter(|w| *w > 0); + let height = integer(&document, media, "ScoredProperty", "MediaSizeHeight").filter(|h| *h > 0); let without_a_size = spool_error("the printer's driver gave a paper without a size (a roll?); nothing was sent"); let size = match (width, height, paper) { (Some(w), Some(h), _) => (w, h), @@ -416,7 +453,9 @@ pub(crate) fn one_job_sides(xml: &str) -> Option { _ => return None, } } - if features(&["JobNUpAllDocumentsContiguously", "DocumentNUp"]).any(|f| integer(f, "ScoredProperty", "PagesPerSheet").is_some_and(|n| n > 1)) { + if features(&["JobNUpAllDocumentsContiguously", "DocumentNUp"]) + .any(|f| integer(&document, f, "ScoredProperty", "PagesPerSheet").is_some_and(|n| n > 1)) + { return None; } if features(&["JobBindAllDocuments", "DocumentBinding", "JobStapleAllDocuments", "DocumentStaple"]) @@ -453,7 +492,7 @@ pub(crate) fn verify_job_options(xml: &str, job: &Job, pages: usize, offered: Of }; let refused = |field: &str| spool_error(format!("the printer's driver did not retain the requested {field}; nothing was sent")); let copies = job.copies.clamp(1, 999); - if integer(root, "ParameterInit", "JobCopiesAllDocuments") != Some(copies) { + if integer(&document, root, "ParameterInit", "JobCopiesAllDocuments") != Some(copies) { return Err(refused("copy count")); } if copies > 1 && pages > 1 && !option("DocumentCollate").is_some_and(|n| named(n, if job.collate { "Collated" } else { "Uncollated" })) { @@ -529,19 +568,7 @@ fn feature_option(document: &roxmltree::Document<'_>, feature: &str) -> Option value, - reference => { - let parameter = expanded(reference, reference.attribute("name")?); - document - .root_element() - .children() - .filter(|p| p.has_tag_name((NS_PSF, "ParameterInit"))) - .find(|p| p.attribute("name").is_some_and(|name| expanded(*p, name) == parameter))? - .children() - .find(|v| v.has_tag_name((NS_PSF, "Value")))? - } - }; + let value = resolved_value(document, n)?; let text = value.text()?.trim(); let value = match text.parse::() { _ if is_qname_value(value) => expanded(value, text), @@ -798,11 +825,11 @@ pub(crate) fn prefixes_unlike_the_drivers(ticket: &str, driver: &str) -> Option< pub(crate) fn imageable_margin(capabilities_xml: &str) -> Option { let document = crate::xml::parse(capabilities_xml).ok()?; let imageable = document.root_element().children().find(|n| n.has_tag_name((NS_PSF, "Property")) && named(*n, "PageImageableSize"))?; - let width = integer(imageable, "Property", "ImageableSizeWidth")?; - let height = integer(imageable, "Property", "ImageableSizeHeight")?; + let width = integer(&document, imageable, "Property", "ImageableSizeWidth")?; + let height = integer(&document, imageable, "Property", "ImageableSizeHeight")?; let area = imageable.children().find(|n| n.has_tag_name((NS_PSF, "Property")) && named(*n, "ImageableArea"))?; - let (x, y) = (integer(area, "Property", "OriginWidth")?, integer(area, "Property", "OriginHeight")?); - let (w, h) = (integer(area, "Property", "ExtentWidth")?, integer(area, "Property", "ExtentHeight")?); + let (x, y) = (integer(&document, area, "Property", "OriginWidth")?, integer(&document, area, "Property", "OriginHeight")?); + let (w, h) = (integer(&document, area, "Property", "ExtentWidth")?, integer(&document, area, "Property", "ExtentHeight")?); let right = width.checked_sub(x)?.checked_sub(w)?; let bottom = height.checked_sub(y)?.checked_sub(h)?; Some(x.max(y).max(right).max(bottom)) @@ -1139,6 +1166,40 @@ mod tests { } } + /// A validated ticket whose width the option refers to a parameter the ticket sets + /// (`MediaSizeWidth` a `ParameterRef`, storytold/craft-libs#1225), as a driver writes one. + fn validated_referenced(width: u32, height: u32) -> String { + format!( + r#" +{width} + + +{height} + + + +"# + ) + } + + /// A paper side a driver keeps in a `ParameterRef` is resolved through the `ParameterInit` + /// the ticket sets, and checked like a direct one: a referenced width that disagrees with the + /// sheet the request names is refused, one that agrees is taken. + #[test] + fn a_referenced_paper_side_is_resolved_through_the_parameter_it_names() { + let a4 = (210_000, 297_000); + assert_eq!(verify_ticket(&validated_referenced(210_000, 297_000), Some(a4), false), Ok(a4)); + assert_eq!(verify_ticket(&validated_referenced(209_750, 297_000), Some(a4), false), Ok((209_750, 297_000)), "exactly the tolerance"); + // A referenced size that disagrees is refused, not rewritten from the request. + let changed = verify_ticket(&validated_referenced(400_000, 297_000), Some(a4), false).unwrap_err(); + assert!(changed.to_string().contains("changed the paper size"), "{changed}"); + // A reference to a parameter the ticket does not set is a missing side, which falls back + // to the requested paper like any unreadable one. + let unset = + validated_referenced(400_000, 297_000).replace(r#"name="psk:PageMediaSizeWidth">