mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 21:11:52 +00:00
fix(print): carry media option for custom and detected driver papers in spool jobs
This commit is contained in:
1 parent
0c9f8e6e34
commit
43f94aaaa5
5 files changed
+100
-32
No files matched your search
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user