fix(print): carry media option for custom and detected driver papers in spool jobs

This commit is contained in:
Combiler01 committed 2026-11-09 16:13:57 +05:30
1 parent 0c9f8e6e34
commit 43f94aaaa5
5 files changed
+100 -32

No files matched your search

+30 -30
View File
@@ -37,9 +37,9 @@ fn bad(m: impl Into<String>) -> ToolError {
ToolError::InvalidArgs(m.into())
}
/// Parse explicit custom dimensions like "300x500", "300 x 500", "300x500 pt".
/// Parse explicit custom dimensions like "300x500", "300 x 500", "300x500 pt", "Custom.300x500".
fn parse_custom_dimensions(s: &str) -> Option<(f64, f64)> {
let s = s.trim().trim_end_matches("pt").trim_end_matches("points").trim();
let s = s.trim().trim_start_matches("Custom.").trim_start_matches("custom.").trim_end_matches("pt").trim_end_matches("points").trim();
let (w_str, h_str) = s.split_once(['x', 'X', '*'])?;
let w: f64 = w_str.trim().parse().ok()?;
let h: f64 = h_str.trim().parse().ok()?;
@@ -47,8 +47,8 @@ fn parse_custom_dimensions(s: &str) -> Option<(f64, f64)> {
}
/// `doc_print`'s `paper`, by its name, short form, driver-detected paper name, or custom dimensions.
/// Returns `(matching_papers_index, (width, height))`.
fn resolve_paper(name: Option<&str>, printer: Option<&str>) -> Result<(Option<usize>, (f64, f64))> {
/// Returns `((width, height), media_name)`.
fn resolve_paper(name: Option<&str>, printer: Option<&str>) -> Result<((f64, f64), String)> {
if let Some(p) = name {
// 1. Match standard PAPERS presets.
for (index, (n, size)) in PAPERS.iter().enumerate() {
@@ -56,26 +56,20 @@ fn resolve_paper(name: Option<&str>, printer: Option<&str>) -> Result<(Option<us
|| n.replace("US ", "").eq_ignore_ascii_case(p)
|| n.split(" (").next().is_some_and(|label| label.eq_ignore_ascii_case(p))
{
return Ok((Some(index), *size));
let media = print::cups_media(index).unwrap_or("Letter").to_string();
return Ok((*size, media));
}
}
// 2. Match detected driver papers for the given printer.
if let Some(printer_name) = printer {
let driver_papers = spool::printer_papers(printer_name);
if let Some((_, size)) = driver_papers.iter().find(|(n, _)| n.eq_ignore_ascii_case(p)) {
return Ok((print::matching_paper(*size), *size));
if let Some((name, size)) = driver_papers.iter().find(|(n, _)| n.eq_ignore_ascii_case(p)) {
return Ok((*size, print::paper_media(Some(name), *size)));
}
}
// 3. Match detected driver papers across any known printer.
for pr in spool::printers() {
let driver_papers = spool::printer_papers(&pr.name);
if let Some((_, size)) = driver_papers.iter().find(|(n, _)| n.eq_ignore_ascii_case(p)) {
return Ok((print::matching_paper(*size), *size));
}
}
// 4. Match custom dimensions ("<w>x<h>").
// 3. Match custom dimensions ("<w>x<h>").
if let Some(size) = parse_custom_dimensions(p) {
return Ok((print::matching_paper(size), size));
return Ok((size, print::paper_media(None, size)));
}
return Err(bad(format!(
"unknown paper {p:?}; choose one of {}, a paper from printer_papers, or dimensions \"<w>x<h>\"",
@@ -85,19 +79,19 @@ fn resolve_paper(name: Option<&str>, printer: Option<&str>) -> Result<(Option<us
// When paper is omitted and a printer is given, use the printer's configured default paper if readable.
if let Some(printer_name) = printer
&& let Some((_, size)) = spool::printer_default_paper(printer_name)
&& let Some((name, size)) = spool::printer_default_paper(printer_name)
{
return Ok((print::matching_paper(size), size));
return Ok((size, print::paper_media(Some(&name), size)));
}
// Default fallback: US Letter.
Ok((Some(0), PAPERS[0].1))
Ok((PAPERS[0].1, "Letter".to_string()))
}
/// The spooler job for `doc_print` to `printer`. It feeds the paper the PDF was laid out on
/// (`PAPERS[paper]`, as `media` after the driver's `options`) rather than the queue's default
/// paper, which a job naming none got (A4 under Letter sheets on the queue in #3752).
fn spool_job(a: &Args, printer: &str, title: String, duplex: Option<spool::Duplex>, paper: Option<usize>) -> Result<spool::Job> {
/// (as `media` after the driver's `options`) rather than the queue's default paper, which a job
/// naming none got (A4 under Letter sheets on the queue in #3752).
fn spool_job(a: &Args, printer: &str, title: String, duplex: Option<spool::Duplex>, media: Option<&str>) -> Result<spool::Job> {
let options = match a.get("options") {
None => Vec::new(),
Some(Value::Object(m)) => m
@@ -117,7 +111,7 @@ fn spool_job(a: &Args, printer: &str, title: String, duplex: Option<spool::Duple
scaling: spool::Scaling::Exact,
grayscale: a.opt_bool("grayscale")?.unwrap_or(false),
title,
options: options.into_iter().chain(paper.and_then(print::media_option)).collect(),
options: options.into_iter().chain(media.map(|m| ("media".to_string(), m.to_string()))).collect(),
})
}
@@ -247,7 +241,7 @@ impl Automation {
p.to_string()
}
});
let (paper_index, paper) = resolve_paper(a.opt_str("paper")?, resolved_printer.as_deref())?;
let (paper, media) = resolve_paper(a.opt_str("paper")?, resolved_printer.as_deref())?;
// Print as image (Acrobat's Advanced Print Setup): pictures at `dpi`, grey when
// `grayscale` is also asked for.
let raster = if a.opt_bool("as_image")?.unwrap_or(false) {
@@ -272,7 +266,7 @@ impl Automation {
}
(None, Some(printer)) => {
let actual_printer = resolved_printer.as_deref().unwrap_or(printer);
let job = spool_job(a, actual_printer, self.doc(a)?.name.clone(), duplex, paper_index)?;
let job = spool_job(a, actual_printer, self.doc(a)?.name.clone(), duplex, Some(&media))?;
out["job"] = json!(spool::submit(&bytes, &job).map_err(|e| failed(e.to_string()))?);
}
_ => return Err(bad("pass either path (save the print-ready PDF) or printer (a name from printers, or \"default\")")),
@@ -316,8 +310,8 @@ mod tests {
#[test]
fn printing_to_a_printer_feeds_the_laid_out_paper() {
let job = |args: Value, paper_name: Option<&str>| {
let (index, _) = resolve_paper(paper_name, None).unwrap();
spool_job(&Args(&args), "Office", "a.pdf".into(), None, index).unwrap()
let (_, media) = resolve_paper(paper_name, None).unwrap();
spool_job(&Args(&args), "Office", "a.pdf".into(), None, Some(&media)).unwrap()
};
let media = |m: &str| ("media".to_string(), m.to_string());
// US Letter when no paper is asked for, after the driver's own options.
@@ -326,14 +320,20 @@ mod tests {
assert_eq!((letter.printer.as_deref(), letter.title.as_str()), (Some("Office"), "a.pdf"));
// Every paper, by its full name, goes out as its own media name.
for (i, (name, size)) in PAPERS.iter().enumerate() {
assert_eq!(resolve_paper(Some(name), None).ok(), Some((Some(i), *size)), "{name}");
assert_eq!(resolve_paper(Some(name), None).ok(), Some((*size, print::cups_media(i).unwrap().to_string())), "{name}");
assert_eq!(job(json!({}), Some(name)).options, print::media_option(i).into_iter().collect::<Vec<_>>(), "{name}");
}
// Short forms find the same paper.
assert_eq!(job(json!({}), Some("letter")).options, [media("Letter")]);
assert_eq!(job(json!({}), Some("ARCH D")).options, [media("na_arch-d_24x36in")]);
// Non-preset dimensions and driver papers carry their media selection.
assert_eq!(job(json!({}), Some("300x500")).options, [media("Custom.300x500")]);
assert_eq!(job(json!({}), Some("300 x 500 pt")).options, [media("Custom.300x500")]);
assert_eq!(job(json!({}), Some("Custom.300x500")).options, [media("Custom.300x500")]);
assert!(matches!(resolve_paper(Some("Quarto"), None), Err(ToolError::InvalidArgs(m)) if m.contains("US Letter")));
assert_eq!(spool_job(&Args(&json!({})), "default", String::new(), None, Some(0)).ok().map(|j| j.printer), Some(None));
assert!(spool_job(&Args(&json!({ "options": { "InputSlot": 3 } })), "Office", String::new(), None, Some(0)).is_err());
assert_eq!(spool_job(&Args(&json!({})), "default", String::new(), None, Some("Letter")).ok().map(|j| j.printer), Some(None));
assert!(spool_job(&Args(&json!({ "options": { "InputSlot": 3 } })), "Office", String::new(), None, Some("Letter")).is_err());
}
}
+1 -1
View File
@@ -38,7 +38,7 @@ use imposition::{PAGES_LISTED, appearance, appearance_matrix, decoded_contents,
pub use imposition::{impose, keep_printed, sheet_pages};
pub use settings::{
Binding, BookletSubset, Content, Crop, LABEL_PAPER, Layout, Orientation, PAPERS, PageOrder, Raster, Settings, SizeMode, booklet_duplex,
cups_media, media_option,
cups_media, custom_media, media_option, paper_media, paper_media_option,
};
#[cfg(test)]
use sheets::text_direction;
+46
View File
@@ -1,4 +1,5 @@
use super::spool;
use crate::matching_paper;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Orientation {
@@ -248,3 +249,48 @@ pub fn cups_media(index: usize) -> Option<&'static str> {
pub fn media_option(index: usize) -> Option<(String, String)> {
cups_media(index).map(|m| ("media".to_string(), m.to_string()))
}
/// Format custom dimensions in points as a CUPS custom media string (e.g. `Custom.300x500`).
pub fn custom_media(size: (f64, f64)) -> String {
let format_dim = |d: f64| {
if (d.round() - d).abs() < 1e-4 {
format!("{:.0}", d.round())
} else {
let s = format!("{d:.2}");
s.trim_end_matches('0').trim_end_matches('.').to_string()
}
};
format!("Custom.{}x{}", format_dim(size.0), format_dim(size.1))
}
/// The CUPS media name for a paper by its name and dimensions.
///
/// Matches standard [`PAPERS`] presets by size first, then a clean driver-reported keyword,
/// falling back to custom media dimensions (`Custom.<width>x<height>`).
pub fn paper_media(name: Option<&str>, size: (f64, f64)) -> String {
if let Some(index) = matching_paper(size)
&& let Some(media) = cups_media(index)
{
return media.to_string();
}
if let Some(n) = name {
let is_dim = n.split_once(['x', 'X', '*']).is_some_and(|(w, h)| {
w.trim().trim_start_matches("Custom.").trim_start_matches("custom.").parse::<f64>().is_ok()
&& h.trim().trim_end_matches("pt").trim_end_matches("points").trim().parse::<f64>().is_ok()
});
let is_clean = !n.is_empty()
&& n.len() <= 64
&& !n.contains(' ')
&& !is_dim
&& n.chars().all(|c| c.is_ascii_graphic() && !matches!(c, '/' | ':' | '=' | '"' | '\'' | '\\' | '{'));
if is_clean {
return n.to_string();
}
}
custom_media(size)
}
/// The media job option (`("media", <name>)`) for a paper by its optional name and dimensions.
pub fn paper_media_option(name: Option<&str>, size: (f64, f64)) -> (String, String) {
("media".to_string(), paper_media(name, size))
}
+22
View File
@@ -852,6 +852,28 @@ fn every_paper_reaches_the_spooler_by_a_name_of_its_size() {
);
}
#[test]
fn paper_media_and_custom_dimensions() {
assert_eq!(custom_media((300.0, 500.0)), "Custom.300x500");
assert_eq!(custom_media((300.5, 500.25)), "Custom.300.5x500.25");
// Standard presets match by size regardless of name:
assert_eq!(paper_media(None, (612.0, 792.0)), "Letter");
assert_eq!(paper_media(Some("Driver Letter"), (612.0, 792.0)), "Letter");
assert_eq!(paper_media(Some("Shipping Label"), (288.0, 432.0)), "w288h432");
// Clean driver keyword that does not match PAPERS presets:
assert_eq!(paper_media(Some("Roll80mm"), (226.77, 800.0)), "Roll80mm");
assert_eq!(paper_media(Some("Photo5x7"), (360.0, 504.0)), "Photo5x7");
// Driver paper with spaces or raw dimensions falls back to Custom:
assert_eq!(paper_media(Some("Custom 5x7"), (360.0, 504.0)), "Custom.360x504");
assert_eq!(paper_media(Some("300x500"), (300.0, 500.0)), "Custom.300x500");
assert_eq!(paper_media(None, (300.0, 500.0)), "Custom.300x500");
assert_eq!(paper_media_option(Some("300x500"), (300.0, 500.0)), ("media".to_string(), "Custom.300x500".to_string()));
}
/// #2734: the US #10 envelope (4.125 × 9.5 in) is offered, fed by its PPD name and laid out at
/// its own size.
#[test]
@@ -421,7 +421,7 @@ impl PrintDraft {
}
.into_iter()
// The sheet the PDF was laid out on, not the queue's default paper.
.chain(print::media_option(self.paper))
.chain(self.papers.get(self.paper).map(|(name, size)| print::paper_media_option(Some(name), *size)))
.collect(),
}
}