fix(print): match the requested paper identity among partial records and resolve parameter references

Two review findings on #1225:

A partial paper record was accepted as a candidate of last resort on its
known side alone, so a partial Letter and a partial Legal sharing a
width both agreed with a Legal request and the first one in the driver's
order won - the ticket could select Letter while the layout assumed
Legal. Partial candidates now resolve ambiguity the way full ones do:
one whose name matches the requested standard sheet wins; an ambiguity
that leaves more than one (or none) is not resolved and nothing is
selected.

integer() read only a direct Value, so a paper dimension supplied
through a ParameterRef read as missing and the fallback silently
substituted the requested size for a ticket that named a different one.
The resolution logic the driver's ticket reader already had moves into a
shared resolved_value() helper, and integer() uses it: a referenced
dimension now either agrees or refuses.
This commit is contained in:
Bra1nZ committed 2026-11-14 12:55:59 +03:00
1 parent ef03e1b1df
commit 82e3341018
1 file changed
+113 -28
+113 -28
View File
@@ -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<usize> {
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<usize> = 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<usize> = 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<bool> {
)
}
/// The integer value of the Print Schema element under `node` with this keyword name.
fn integer(node: roxmltree::Node<'_, '_>, tag: &str, name: &str) -> Option<u32> {
/// 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<roxmltree::Node<'a, 'a>> {
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<u32> {
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::<u32>().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<bool> {
_ => 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<F
.children()
.filter(|n| n.has_tag_name((NS_PSF, "ScoredProperty")))
.filter_map(|n| {
let value = match n.children().find(|v| v.has_tag_name((NS_PSF, "Value")) || v.has_tag_name((NS_PSF, "ParameterRef")))? {
value if value.has_tag_name((NS_PSF, "Value")) => 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::<i64>() {
_ 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<u32> {
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#"<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:ParameterInit name="psk:PageMediaSizeWidth"><psf:Value xsi:type="xsd:integer">{width}</psf:Value></psf:ParameterInit>
<psf:Feature name="psk:PageMediaSize"><psf:Option name="psk:ISOA4">
<psf:ScoredProperty name="psk:MediaSizeWidth"><psf:ParameterRef name="psk:PageMediaSizeWidth"/></psf:ScoredProperty>
<psf:ScoredProperty name="psk:MediaSizeHeight"><psf:Value xsi:type="xsd:integer">{height}</psf:Value></psf:ScoredProperty>
</psf:Option></psf:Feature>
<psf:Feature name="psk:PageOrientation"><psf:Option name="psk:Portrait"/></psf:Feature>
<psf:Feature name="psk:PageScaling"><psf:Option name="psk:None"/></psf:Feature>
</psf:PrintTicket>"#
)
}
/// 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"><psf:Value"#, r#"name="psk:OtherParameter"><psf:Value"#);
assert_eq!(verify_ticket(&unset, Some(a4), false), Ok(a4), "a parameter the ticket does not set is a missing side");
}
/// The namespace of the Brother driver's own keywords.
const BROTHER: &str = "http://schemas.brother.info/mfc/printing/2006/11/printschemakeywords";
@@ -1649,6 +1710,30 @@ mod tests {
assert_eq!(matching_paper(&[paper(0, 0, Some("A4"), true)], 595.28, 841.89), None);
}
/// One partial paper whose known side agrees is still taken, named like the requested
/// standard sheet or not.
#[test]
fn a_single_partially_known_paper_that_agrees_is_still_taken() {
let paper = |width, height, name, keyword| Paper { width, height, name, keyword };
assert_eq!(matching_paper(&[paper(215_900, 0, Some("Legal 215.9 x 355.6 mm"), true)], 612.0, 1008.0), Some(0), "named like the sheet");
assert_eq!(matching_paper(&[paper(215_900, 0, None, true)], 612.0, 1008.0), Some(0), "or without a name at all");
}
/// storytold/craft-libs#1225: two partial papers that both agree (Letter and Legal share a
/// width) are told apart by name, as full candidates are; when nothing tells them apart,
/// none is chosen, not the first one that agrees.
#[test]
fn two_partially_known_papers_that_agree_are_told_apart_by_their_names() {
let paper = |width, height, name, keyword| Paper { width, height, name, keyword };
let both = [paper(215_900, 0, Some("Letter"), true), paper(215_900, 0, Some("Legal"), true)];
assert_eq!(matching_paper(&both, 612.0, 1008.0), Some(1), "the partial named like the sheet wins");
assert_eq!(matching_paper(&both, 612.0, 792.0), Some(0), "and for a Letter sheet, the Letter one");
let unnamed = [paper(215_900, 0, None, true), paper(215_900, 0, None, true)];
assert_eq!(matching_paper(&unnamed, 612.0, 1008.0), None, "nothing to tell them apart by");
let same_name = [paper(215_900, 0, Some("Legal"), true), paper(215_900, 0, Some("Legal"), true)];
assert_eq!(matching_paper(&same_name, 612.0, 1008.0), None, "two of the same name are ambiguous too");
}
#[test]
fn the_unprintable_margin_comes_from_the_capabilities() {
let caps = |x: u32, y: u32, w: u32, h: u32| {