fix(print): trust one side of a legacy V3 driver's paper metric

Legacy V3 GDI drivers (HP LaserJet P1102) keep the paper metric in their
DEVMODE extension and report MediaSizeWidth = 0 while the height is right,
so verify_ticket refused with "a paper without a size (a roll?)" and
nothing was sent (storytold/craft-libs#7230).

verify_ticket now fails closed only when neither side is known or the
request itself names no sheet; otherwise it goes on with the requested
size, but only while the side the driver did report agrees with the
request to a quarter of a millimetre. matching_paper gains a partial
record as a candidate of last resort, and driver_ticket takes the wanted
size from the sheet the job asked for, not from the zero record.
The fallback is logged; log moves to the common dependencies so the
warning compiles on Windows too (caught by the windows-gnu check).
This commit is contained in:
Bra1nZ committed 2026-11-14 01:19:29 +03:00
1 parent 75c70fa6a7
commit ef03e1b1df
3 files changed
+132 -10

No files matched your search

+4 -4
View File
@@ -13,14 +13,14 @@ description = "System PDF print spooler for the Craft apps: CUPS and Windows"
# downstream GDI values a different Rust type identity even when the code is unchanged.
# PDF loader updates below are independent of this public transport API.
craft-winprint-gdi = { git = "https://github.com/storytold/winprint.rs", rev = "64e7afd4d1483512385012c340ecaaaf0f3beb7f" }
# Says why the printer list is empty when lpstat can't run (e.g. a Flatpak without CUPS tools),
# and why a job went on with the paper the request names while the driver reported only one side
# of it (storytold/craft-libs#7230).
log = "0.4"
[lints]
workspace = true
[target.'cfg(unix)'.dependencies]
# Says why the printer list is empty when lpstat can't run (e.g. a Flatpak without CUPS tools).
log = "0.4"
[target.'cfg(windows)'.dependencies]
# Printing without starting anything (storytold/pdfcraft#1107): the printer list (EnumPrinters)
# and the job (Windows.Data.Pdf drawn with Direct2D into the XPS print API, options as a
+106 -5
View File
@@ -218,6 +218,11 @@ pub(crate) struct Paper<'a> {
/// The driver paper for a sheet of `width` × `height` points (either way round): within a
/// quarter of a millimetre, preferring the one named like the standard size, then a Print Schema
/// keyword, then the closest; on a tie the driver's own order. Its index in `papers`.
///
/// 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.
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);
@@ -231,7 +236,13 @@ pub(crate) fn matching_paper(papers: &[Paper<'_>], width: f64, height: f64) -> O
})
.collect();
candidates.sort_by_key(|(_, p, off)| (!names.iter().any(|n| names_paper(p.name, n)), !p.keyword, *off));
candidates.first().map(|(i, _, _)| *i)
if let Some(index) = candidates.first().map(|(i, _, _)| *i) {
return Some(index);
}
let agrees = |side: u32| side.abs_diff(w) <= PAPER_TOLERANCE || side.abs_diff(h) <= PAPER_TOLERANCE;
papers
.iter()
.position(|p| p.keyword && (p.width == 0) != (p.height == 0) && (p.width == 0 || agrees(p.width)) && (p.height == 0 || agrees(p.height)))
}
fn named(node: roxmltree::Node<'_, '_>, local: &str) -> bool {
@@ -287,14 +298,35 @@ fn integer(node: roxmltree::Node<'_, '_>, tag: &str, name: &str) -> Option<u32>
/// 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:
/// some drivers return empty vendor parameters that a stricter parser would refuse.
///
/// A zero or unreadable side (`MediaSizeWidth` gone) is roll-like only while the request itself
/// names no sheet (INV-2): a legacy host-based driver keeps the paper metric in its DEVMODE
/// extension and reads back a zero side for a real sheet (HP LaserJet P1102, storytold/craft-libs#7230), so
/// with a sheet named the job falls back to the size the request asked for (INV-1,
/// fail-closed: every refusal here is before anything reaches the spooler). A side the driver
/// did report must agree with the sheet asked for, either way round (INV-3); both sides gone is
/// refused whatever the request names (INV-5). The fallback is logged (INV-7).
pub(crate) fn verify_ticket(xml: &str, paper: Option<(u32, u32)>, landscape: bool) -> Result<(u32, u32), Error> {
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 size = integer(media, "ScoredProperty", "MediaSizeWidth")
.zip(integer(media, "ScoredProperty", "MediaSizeHeight"))
.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"))?;
let width = integer(media, "ScoredProperty", "MediaSizeWidth").filter(|w| *w > 0);
let height = integer(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),
(None, None, _) | (_, _, None) => return Err(without_a_size),
(Some(reported), None, Some(wanted)) | (None, Some(reported), Some(wanted)) => {
let (w, h) = wanted;
// Either way round: the one side the driver did report against the sheet's width or height.
let agrees = |other: u32| reported.abs_diff(other) <= PAPER_TOLERANCE;
if !agrees(w) && !agrees(h) {
return Err(spool_error("the printer's driver changed the paper size; nothing was sent (choose a paper size it has)"));
}
log::warn!("the printer's driver reported only {reported} µm of the paper; going on with the requested {w} × {h} µm");
(w, h)
}
};
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;
if !same(size, (w, h)) && !same(size, (h, w)) {
@@ -1058,6 +1090,55 @@ mod tests {
assert!(verify_ticket("<not xml", Some(letter), false).is_err());
}
/// A validated ticket that keeps only the sides its DEVMODE extension could read, as a legacy
/// host-based driver (HP LaserJet P1102, storytold/craft-libs#7230) returns one.
fn validated_partial(width: Option<u32>, height: Option<u32>) -> String {
let side = |name: &str, value: Option<u32>| {
value.map_or(String::new(), |v| {
format!(r#"<psf:ScoredProperty name="psk:{name}"><psf:Value xsi:type="xsd:integer">{v}</psf:Value></psf:ScoredProperty>"#)
})
};
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:ISOA4">{}{}</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>"#,
side("MediaSizeWidth", width),
side("MediaSizeHeight", height)
)
}
/// storytold/craft-libs#7230: the driver reports one side of the paper as zero for a real
/// sheet. With the sheet the request names, the job goes on with the size it asked for; a
/// side the driver did report must still agree with it, and a request that names no sheet is
/// refused as before.
#[test]
fn a_legacy_v3_drivers_zero_side_falls_back_to_the_requested_paper() {
let a4 = (210_000, 297_000);
assert_eq!(verify_ticket(&validated_partial(None, Some(297_000)), Some(a4), false), Ok(a4));
assert_eq!(verify_ticket(&validated_partial(Some(0), Some(297_000)), Some(a4), false), Ok(a4), "a zero side is a missing one");
assert_eq!(verify_ticket(&validated_partial(Some(297_000), None), Some(a4), false), Ok(a4), "either way round");
// 0.5 pt (176 µm) inside the tolerance still agrees.
assert_eq!(verify_ticket(&validated_partial(Some(0), Some(297_176)), Some(a4), false), Ok(a4));
assert_eq!(verify_ticket(&validated_partial(Some(0), Some(297_250)), Some(a4), false), Ok(a4), "exactly the tolerance");
// A side that disagrees means the driver changed the paper, not that it has a roll.
let changed = verify_ticket(&validated_partial(Some(0), Some(279_400)), Some(a4), false).unwrap_err();
assert!(changed.to_string().contains("changed the paper size"), "{changed}");
assert!(!changed.to_string().contains("roll"), "{changed}");
assert!(verify_ticket(&validated_partial(Some(0), Some(297_251)), Some(a4), false).is_err(), "one µm past the tolerance");
// Without a sheet named the zero side stays roll-like.
assert!(verify_ticket(&validated_partial(Some(0), Some(297_000)), None, false).is_err());
assert!(verify_ticket(&validated_partial(None, Some(297_000)), None, false).is_err());
// Both sides gone is refused whatever the request names.
for wanted in [Some(a4), None] {
for (width, height) in [(None, None), (Some(0), Some(0))] {
let refused = verify_ticket(&validated_partial(width, height), wanted, false).unwrap_err();
assert!(refused.to_string().contains("roll"), "{refused}");
}
}
}
/// The namespace of the Brother driver's own keywords.
const BROTHER: &str = "http://schemas.brother.info/mfc/printing/2006/11/printschemakeywords";
@@ -1548,6 +1629,26 @@ mod tests {
assert!(!names_paper(None, "A4"));
}
/// A paper the driver keeps one side of (storytold/craft-libs#7230) is a candidate of last
/// resort only: no full entry matched, a Print Schema keyword entry, and its known side
/// agrees with the sheet.
#[test]
fn a_partially_known_paper_is_a_candidate_of_last_resort_only() {
let paper = |width, height, name, keyword| Paper { width, height, name, keyword };
let partial = paper(0, 297_000, Some("A4"), true);
let alone = [partial.clone()];
assert_eq!(matching_paper(&alone, 595.28, 841.89), Some(0), "no full entry: the partial one is taken");
// A full candidate, however it spells its option, wins over the partial one.
let with_full = [partial, paper(210_100, 297_000, Some("A4"), false)];
assert_eq!(matching_paper(&with_full, 595.28, 841.89), Some(1), "the full entry wins");
// A vendor's own entry is no partial candidate.
assert_eq!(matching_paper(&[paper(0, 297_000, Some("A4"), false)], 595.28, 841.89), None);
// A partial entry whose known side disagrees is no candidate.
assert_eq!(matching_paper(&[paper(0, 279_400, Some("Letter"), true)], 595.28, 841.89), None);
// Both sides gone: nothing known to agree with.
assert_eq!(matching_paper(&[paper(0, 0, Some("A4"), true)], 595.28, 841.89), None);
}
#[test]
fn the_unprintable_margin_comes_from_the_capabilities() {
let caps = |x: u32, y: u32, w: u32, h: u32| {
+22 -1
View File
@@ -224,7 +224,28 @@ 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 wanted = match (media, job.scaling) {
(Some(m), _) => Some((m.size().width_in_micron(), m.size().height_in_micron())),
(Some(m), _) => {
let size = (m.size().width_in_micron(), m.size().height_in_micron());
// INV-6: a driver that keeps one side of the paper metric in its DEVMODE extension
// (storytold/craft-libs#7230) reports a zero side; the wanted size then is the sheet
// the job asked for, not the zero record, which would poison the cross-check and hand
// `sheets::plan` a width of zero.
if size.0 == 0 || size.1 == 0 {
match (ticket::micrometres(sheet.0), ticket::micrometres(sheet.1)) {
(Some(w), Some(h)) => Some((w, h)),
_ => {
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
)));
}
}
} else {
Some(size)
}
}
(None, Scaling::ShrinkToFit) => None,
(None, Scaling::Exact) => {
return Err(spool_error(format!(