diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 56c654fa1c..30d0c1b696 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -757,7 +757,7 @@ pub fn tools() -> Vec { t( "printer_papers", "List printer paper sizes", - "The media sizes (names and dimensions in points) and default paper supported by a printer driver. Pass printer: a name from printers, or \"default\".", + "The media sizes (names and dimensions in points) and default paper supported by a printer driver (CUPS: its PPD's, by the driver's names, the default being its *DefaultPageSize; a job on one, or on a standard size of one, is fed it under the PPD's own keyword). Pass printer: a name from printers, or \"default\".", ) .ro() .with(schema(json!({ "printer": { "type": "string" } }), &["printer"])), diff --git a/crates/print/src/spool.rs b/crates/print/src/spool.rs index 73a267daa5..6c05d7d4f6 100644 --- a/crates/print/src/spool.rs +++ b/crates/print/src/spool.rs @@ -194,6 +194,35 @@ pub(crate) fn output_within(command: &mut std::process::Command, limit: std::tim } } +/// The queue of a CUPS destination (`queue` or `queue/instance`); `None` when either part is not +/// a plain name. Neither part is a path: dot components, backslashes and any further separator +/// are refused before I/O. A leading `-` is refused too: `lpoptions` parses its argv with getopt, +/// so `-p -E -l` would read `-E` as an option rather than as the destination, and `--` cannot +/// help because it ends option parsing before the `-p` that names the queue. +#[cfg(all(unix, not(target_arch = "wasm32")))] +fn queue_of(printer: &str) -> Option<&str> { + let (queue, instance) = printer.split_once('/').map_or((printer, None), |(q, i)| (q, Some(i))); + let valid = |name: &str| { + !name.is_empty() + && name.len() <= 64 + && !name.starts_with('-') + && !matches!(name, "." | "..") + && name.bytes().all(|b| b.is_ascii_graphic() && !matches!(b, b'/' | b'\\' | b':' | b'=' | b'"')) + }; + (valid(queue) && instance.is_none_or(valid)).then_some(queue) +} + +/// The queue's PPD in `ppd_dir`, at most 16 MiB of it; `None` when the queue has none. +#[cfg(all(unix, not(target_arch = "wasm32")))] +fn read_ppd(ppd_dir: &std::path::Path, queue: &str) -> Option> { + use std::io::Read as _; + const PPD_CAP: u64 = 16 << 20; + let file = std::fs::File::open(ppd_dir.join(format!("{queue}.ppd"))).ok()?; + let mut bytes = Vec::new(); + file.take(PPD_CAP).read_to_end(&mut bytes).ok()?; + Some(bytes) +} + /// Read a destination's options with the PPD directory and command supplied, so tests need /// neither a system queue nor a physical printer. CUPS instances share their queue's PPD but /// have their own defaults, queried with the complete `queue/instance` destination. `limit` is @@ -206,28 +235,8 @@ pub(crate) fn printer_options_via_within( limit: std::time::Duration, lang: &str, ) -> Vec { - let (queue, instance) = printer.split_once('/').map_or((printer, None), |(q, i)| (q, Some(i))); - // Retain the conservative name checks, applied to each component. Neither component is - // a path: reject dot components, backslashes and any additional separator before I/O. A - // leading `-` is refused too: `lpoptions` parses its argv with getopt, so `-p -E -l` would - // read `-E` as an option rather than as the destination, and `--` cannot help because it - // ends option parsing before the `-p` that names the queue. - let valid = |name: &str| { - !name.is_empty() - && name.len() <= 64 - && !name.starts_with('-') - && !matches!(name, "." | "..") - && name.bytes().all(|b| b.is_ascii_graphic() && !matches!(b, b'/' | b'\\' | b':' | b'=' | b'"')) - }; - if !valid(queue) || instance.is_some_and(|name| !valid(name)) { - return Vec::new(); - } - const PPD_CAP: u64 = 16 << 20; - let ppd = std::fs::File::open(ppd_dir.join(format!("{queue}.ppd"))).ok().and_then(|f| { - use std::io::Read as _; - let mut bytes = Vec::new(); - f.take(PPD_CAP).read_to_end(&mut bytes).ok().map(|_| bytes) - }); + let Some(queue) = queue_of(printer) else { return Vec::new() }; + let ppd = read_ppd(ppd_dir, queue); let mut options = ppd.as_deref().map(|b| parse_ppd(&ppd_text(b))).unwrap_or_default(); let lpoptions_out = output_within(lpoptions.args(["-p", printer, "-l"]), limit).ok(); let current = lpoptions_out.as_deref().map(|out| parse_lpoptions(&String::from_utf8_lossy(out))).unwrap_or_default(); @@ -390,6 +399,8 @@ pub(crate) fn with_job_info(job: &Job, owner: Option<&str>) -> Job { /// The job as the system spooler takes it. fn spooled(job: &Job) -> Job { + #[cfg(all(unix, not(target_arch = "wasm32")))] + let job = &with_ppd_media(job, |printer| ppd_papers_in(printer, std::path::Path::new("/etc/cups/ppd"))); #[cfg(target_os = "macos")] { with_job_info(job, std::env::var("USER").ok().as_deref()) @@ -492,10 +503,206 @@ fn printer_papers_internal(printer: &str) -> Vec { papers } -/// The paper sizes supported by `printer` (fallback for non-Windows platforms). +/// The paper sizes supported by `printer` on CUPS: its PPD's (see [`cups_paper_info`]); none on +/// the web. #[cfg(not(windows))] -pub fn printer_papers(_printer: &str) -> Vec { - Vec::new() +pub fn printer_papers(printer: &str) -> Vec { + cups_paper_info(printer).0 +} + +/// CUPS: the papers of the queue's PPD, each named by the driver's label, and its +/// `*DefaultPageSize` one (the stock a label printer is set up for, an office printer's tray +/// paper); none for a queue without a PPD and on the web. A job on one of them is fed it under +/// the PPD's own keyword ([`with_ppd_media`]). +#[cfg(not(windows))] +fn cups_paper_info(printer: &str) -> (Vec, Option) { + #[cfg(all(unix, not(target_arch = "wasm32")))] + { + cups_paper_info_in(printer, std::path::Path::new("/etc/cups/ppd")) + } + #[cfg(not(all(unix, not(target_arch = "wasm32"))))] + { + let _ = printer; + (Vec::new(), None) + } +} + +/// [`cups_paper_info`] with the PPD directory supplied, so tests need no system queue. +#[cfg(all(unix, not(target_arch = "wasm32")))] +pub(crate) fn cups_paper_info_in(printer: &str, ppd_dir: &std::path::Path) -> (Vec, Option) { + let papers = ppd_papers_in(printer, ppd_dir); + let default = papers.iter().find(|p| p.default).map(|p| (p.label.clone(), p.size)); + (papers.into_iter().map(|p| (p.label, p.size)).collect(), default) +} + +/// A paper of a PPD: one `*PaperDimension` entry. +#[cfg(any(test, all(unix, not(target_arch = "wasm32"))))] +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct PpdPaper { + /// The driver's keyword for it (`Letter`, `w288h432`), which a job's `media` names. + pub key: String, + /// What the driver calls it (`US Letter`); the keyword when it gives no name. + pub label: String, + /// Width and height in points as the driver feeds it: a label's width is across the feed, + /// so a 2.25 × 1.25 in label and a 1.25 × 2.25 in one are different stock. + pub size: (f64, f64), + /// Whether it is the PPD's `*DefaultPageSize`. + pub default: bool, +} + +/// The papers of the queue's PPD in `ppd_dir`; none when the destination is not a plain name or +/// its queue has no PPD. +#[cfg(all(unix, not(target_arch = "wasm32")))] +fn ppd_papers_in(printer: &str, ppd_dir: &std::path::Path) -> Vec { + queue_of(printer).and_then(|queue| read_ppd(ppd_dir, queue)).map(|ppd| parse_ppd_papers(&ppd_text(&ppd))).unwrap_or_default() +} + +/// The largest side a PPD paper may have, in points: PDF's own page limit (200 inches). +#[cfg(any(test, all(unix, not(target_arch = "wasm32"))))] +const MAX_PAPER_SIDE: f64 = 14_400.0; +/// How many papers a PPD may list (PPDs are text files from the printer vendor). +#[cfg(any(test, all(unix, not(target_arch = "wasm32"))))] +const MAX_PAPERS: usize = 400; + +/// The paper sizes a PPD offers: its `*PaperDimension key/label: "width height"` entries in +/// file order and as written, each named by its own label, else its `*PageSize` label, else the +/// keyword, the `*DefaultPageSize` one marked as the default. Custom-size placeholders, +/// translations (`*fr.PaperDimension`), repeated keywords, keywords that could not be passed as +/// a CUPS option value, and sizes outside 1 pt to 200 in are left out. Lenient: malformed lines +/// are skipped. +#[cfg(any(test, all(unix, not(target_arch = "wasm32"))))] +pub(crate) fn parse_ppd_papers(ppd: &str) -> Vec { + // A keyword goes to `lp -o media=`: no space, quote, separator or `=`. + let keyword = |s: &str| { + !s.is_empty() && s.len() <= 64 && s.bytes().all(|b| b.is_ascii_graphic() && !matches!(b, b'/' | b':' | b'=' | b'"' | b'\'' | b'\\' | b',')) + }; + // `*Prefix key/label: value` → (key, label, value), the label falling back to the key. + let entry = |line: &str, prefix: &str| -> Option<(String, String, String)> { + let (head, value) = line.trim_end_matches('\r').strip_prefix(prefix)?.split_once(':')?; + let head = head.trim(); + let (key, label) = match head.split_once('/') { + Some((k, l)) if !l.trim().is_empty() => (k.trim(), l.trim()), + Some((k, _)) => (k.trim(), k.trim()), + None => (head, head), + }; + keyword(key).then(|| (key.to_string(), label.to_string(), value.trim().to_string())) + }; + let mut names: std::collections::BTreeMap = Default::default(); + for line in ppd.lines() { + if let Some((key, label, _)) = entry(line, "*PageSize ") + && label != key + { + names.entry(key).or_insert(label); + } + } + let default_key = ppd + .lines() + .find_map(|line| line.trim_end_matches('\r').strip_prefix("*DefaultPageSize:").map(str::trim)) + .filter(|k| keyword(k)) + .map(str::to_string); + let fits = |v: f64| v.is_finite() && (1.0..=MAX_PAPER_SIDE).contains(&v); + let mut out: Vec = Vec::new(); + for line in ppd.lines() { + if out.len() >= MAX_PAPERS { + break; + } + let Some((key, label, value)) = entry(line, "*PaperDimension ") else { continue }; + if key.starts_with("Custom") || out.iter().any(|p| p.key == key) { + continue; + } + let mut sides = value.trim_matches('"').split_whitespace().map(|v| v.parse::().ok()); + let (Some(Some(w)), Some(Some(h))) = (sides.next(), sides.next()) else { continue }; + if !fits(w) || !fits(h) { + continue; + } + let label = if label == key { names.get(&key).cloned().unwrap_or(label) } else { label }; + let default = default_key.as_deref() == Some(key.as_str()); + out.push(PpdPaper { key, label, size: (w, h), default }); + } + out +} + +/// The paper of `papers` of this size, within half a point a side: the one fed that way round +/// first (a label's width is its width across the feed), else the one fed the other way round. +#[cfg(any(test, all(unix, not(target_arch = "wasm32"))))] +pub(crate) fn ppd_paper_of_size(papers: &[PpdPaper], (w, h): (f64, f64)) -> Option<&PpdPaper> { + let same = |p: &&PpdPaper, (w, h): (f64, f64)| (p.size.0 - w).abs() <= 0.5 && (p.size.1 - h).abs() <= 0.5; + papers.iter().find(|p| same(p, (w, h))).or_else(|| papers.iter().find(|p| same(p, (h, w)))) +} + +/// How far a side of a PPD paper may be from a standard name's nominal size and still be that +/// paper: Adobe's 5 points, which CUPS also uses to find the PPD size for a media name. A driver +/// may measure a stock in millimetres (a "4 × 6 in" label of 102 × 152 mm is 1.1 pt off), and +/// neighbouring label sizes differ by more. +#[cfg(any(test, all(unix, not(target_arch = "wasm32"))))] +const NOMINAL_SIZE_EPSILON: f64 = 5.0; + +/// The paper of `papers` nearest a nominal size, within [`NOMINAL_SIZE_EPSILON`] a side, fed +/// either way round; on a tie the one fed that way round, then the first. +#[cfg(any(test, all(unix, not(target_arch = "wasm32"))))] +pub(crate) fn ppd_paper_near_size(papers: &[PpdPaper], (w, h): (f64, f64)) -> Option<&PpdPaper> { + let off = |p: &PpdPaper, (w, h): (f64, f64)| { + let (dw, dh) = ((p.size.0 - w).abs(), (p.size.1 - h).abs()); + (dw <= NOMINAL_SIZE_EPSILON && dh <= NOMINAL_SIZE_EPSILON).then_some(dw + dh) + }; + let fed_as_is = papers.iter().filter_map(|p| Some((off(p, (w, h))?, 0, p))); + let turned = papers.iter().filter_map(|p| Some((off(p, (h, w))?, 1, p))); + fed_as_is.chain(turned).min_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1))).map(|(_, _, p)| p) +} + +/// The size in points that a standard media name stands for: one of [`crate::cups_media`]'s +/// names, a PPD-style `wh` in points, or a PWG 5101.1 self-describing name +/// (`na_index-4x6_4x6in`, `iso_a6_105x148mm`). `None` for any other name. +#[cfg(any(test, all(unix, not(target_arch = "wasm32"))))] +pub(crate) fn nominal_media_size(media: &str) -> Option<(f64, f64)> { + let sides = |s: &str, x: char, scale: f64| { + let (w, h) = s.split_once(x)?; + let (w, h) = (w.parse::().ok()? * scale, h.parse::().ok()? * scale); + (w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0).then_some((w, h)) + }; + if let Some(index) = (0..crate::PAPERS.len()).find(|&i| crate::cups_media(i).is_some_and(|m| m.eq_ignore_ascii_case(media))) { + return crate::PAPERS.get(index).map(|(_, size)| *size); + } + if let Some(size) = media.strip_prefix('w').and_then(|s| sides(s, 'h', 1.0)) { + return Some(size); + } + let (class_name, dims) = media.rsplit_once('_')?; + if !class_name.contains('_') { + return None; + } + if let Some(dims) = dims.strip_suffix("in") { + return sides(dims, 'x', 72.0); + } + sides(dims.strip_suffix("mm")?, 'x', 72.0 / 25.4) +} + +/// CUPS: the job with its `media` named by the printer's own PPD keyword when it names one of +/// the PPD's papers by its label (as [`printer_papers`] lists them), as `Custom.x` points +/// of one of their sizes ([`crate::custom_media`]), or by a standard name of one of their sizes +/// ([`nominal_media_size`], such as the `w288h432` of PdfCraft's 4 × 6 in label). Fed under the +/// PPD's keyword, the paper is the driver's own: a driver may take no custom sizes, and a label +/// printer's stock is set up under its keyword. A keyword the PPD has, any other value, a job +/// without `media` and one for the default destination go as they are. +#[cfg(any(test, all(unix, not(target_arch = "wasm32"))))] +pub(crate) fn with_ppd_media(job: &Job, papers: impl FnOnce(&str) -> Vec) -> Job { + let mut job = job.clone(); + let Some(printer) = job.printer.clone() else { return job }; + let Some(media) = job.options.iter_mut().rev().find(|(k, _)| k.eq_ignore_ascii_case("media")) else { return job }; + let papers = papers(&printer); + if papers.iter().any(|p| p.key == media.1) { + return job; + } + let custom = + media.1.strip_prefix("Custom.").and_then(|s| s.split_once('x')).and_then(|(w, h)| Some((w.parse::().ok()?, h.parse::().ok()?))); + let paper = papers + .iter() + .find(|p| p.label == media.1) + .or_else(|| custom.and_then(|size| ppd_paper_of_size(&papers, size))) + .or_else(|| nominal_media_size(&media.1).and_then(|size| ppd_paper_near_size(&papers, size))); + if let Some(paper) = paper { + media.1 = paper.key.clone(); + } + job } /// Match `paper_pts` against detected driver `papers` within a 1.0 pt tolerance. @@ -528,7 +735,7 @@ pub fn printer_paper_info(printer: &str) -> (Vec, Option) { #[cfg(windows)] let papers = printer_papers_internal(printer); #[cfg(not(windows))] - let papers = printer_papers(printer); + let (papers, cups_default) = cups_paper_info(printer); #[cfg(windows)] let default_paper = { let settings = craft_print::rgb::default_settings(printer).ok(); @@ -536,10 +743,7 @@ pub fn printer_paper_info(printer: &str) -> (Vec, Option) { page.map(|p| resolve_paper_name(&papers, p.paper)) }; #[cfg(not(windows))] - let default_paper = { - let _ = &papers; - None - }; + let default_paper = cups_default; (papers, default_paper) } diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index 98a37a98cc..057986901e 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -801,6 +801,137 @@ fn a_job_that_names_its_orientation_keeps_it() { } } +/// A label printer's PPD: `*PageSize` labels, `*PaperDimension` sizes, and the lines that +/// must not become papers (custom placeholders, repeats, translations, nonsense, malformed). +const LABEL_PPD: &str = "*PPD-Adobe: \"4.3\"\n\ +*OpenUI *PageSize/Media Size: PickOne\n\ +*DefaultPageSize: w288h432\n\ +*PageSize Letter/US Letter: \"<>setpagedevice\"\n\ +*PageSize w288h432/4 x 6 in: \"\"\n\ +*PageSize Env10/#10 Envelope: \"\"\n\ +*PageSize Wide/Wide: \"\"\n\ +*PageSize Custom.WIDTHxHEIGHT/Custom: \"\"\n\ +*fr.PageSize Letter/Lettre US: \"\"\n\ +*CloseUI: *PageSize\n\ +*PaperDimension Letter: \"612.0000 792.0000\"\r\n\ +*PaperDimension w288h432: \"288 432\"\n\ +*PaperDimension Env10/Envelope #10: \"297 684\"\n\ +*PaperDimension Wide: \"1224 792\"\n\ +*PaperDimension Custom: \"0 0\"\n\ +*PaperDimension Letter: \"1 1\"\n\ +*fr.PaperDimension Letter/Lettre: \"612 792\"\n\ +*PaperDimension Huge: \"20000 300\"\n\ +*PaperDimension Tiny: \"0.5 300\"\n\ +*PaperDimension Broken: \"abc 300\"\n\ +*PaperDimension Short: \"300\"\n\ +*PaperDimension NoColon \"1 2\"\n\ +*PaperDimension Bad/Key: \"300 300\"\n\ +*PaperDimension Two Words: \"300 300\"\n\ +*PaperDimension media=x: \"300 300\"\n\ +*PaperDimension Quo\"te: \"300 300\"\n\ +*PaperDimension a,b: \"300 300\"\n"; + +#[test] +fn ppd_papers_come_from_the_ppd() { + let papers = spool::parse_ppd_papers(LABEL_PPD); + let listed: Vec<(&str, &str, (f64, f64))> = papers.iter().map(|p| (p.key.as_str(), p.label.as_str(), p.size)).collect(); + assert_eq!( + listed, + [ + ("Letter", "US Letter", (612.0, 792.0)), + ("w288h432", "4 x 6 in", (288.0, 432.0)), + ("Env10", "Envelope #10", (297.0, 684.0)), + ("Wide", "Wide", (1224.0, 792.0)), + ("Bad", "Key", (300.0, 300.0)), + ], + "a *PageSize label names a size, its own label wins, sizes stay as the driver feeds them; placeholders, repeats, translations, impossible sizes, malformed lines and keywords that would break a CUPS option (a space, `=`, a quote, a comma) are left out" + ); + assert_eq!(papers.iter().map(|p| p.default).collect::>(), [false, true, false, false, false], "the *DefaultPageSize one is the default"); + let key = |size| spool::ppd_paper_of_size(&papers, size).map(|p| p.key.as_str()); + assert_eq!(key((432.0, 288.0)), Some("w288h432"), "the other way round too"); + assert_eq!(key((612.4, 792.4)), Some("Letter"), "within half a point"); + assert_eq!(key((613.0, 792.0)), None); + assert_eq!(key((f64::NAN, 792.0)), None); + // A label fed either way is different stock: the exact way round wins when both exist. + let labels = spool::parse_ppd_papers("*PaperDimension w90h162/1.25x2.25: \"90 162\"\n*PaperDimension w162h90/2.25x1.25: \"162 90\"\n"); + let key = |size| spool::ppd_paper_of_size(&labels, size).map(|p| p.key.as_str()); + assert_eq!((key((162.0, 90.0)), key((90.0, 162.0))), (Some("w162h90"), Some("w90h162"))); + assert!(spool::parse_ppd_papers("").is_empty()); + assert!(spool::parse_ppd_papers("*PaperDimension").is_empty()); + let many: String = (0..500).map(|i| format!("*PaperDimension P{i}: \"100 200\"\n")).collect(); + assert_eq!(spool::parse_ppd_papers(&many).len(), 400, "capped"); +} + +/// A CUPS job on one of the PPD's papers, as the Print dialog and `doc_print` name it (by its +/// label, or as custom points of its size), is fed it under the PPD's keyword; anything else +/// goes as it is. +#[test] +fn a_job_on_a_ppd_paper_names_the_ppd_keyword() { + let papers = |printer: &str| if printer == "Label" { spool::parse_ppd_papers(LABEL_PPD) } else { Vec::new() }; + let media = |printer: Option<&str>, options: &[(&str, &str)]| { + let job = Job { + printer: printer.map(Into::into), + options: options.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(), + ..Job::default() + }; + spool::with_ppd_media(&job, papers).options.into_iter().map(|(k, v)| format!("{k}={v}")).collect::>() + }; + assert_eq!(media(Some("Label"), &[("media", "Custom.288x432")]), ["media=w288h432"], "custom points of a PPD size"); + assert_eq!(media(Some("Label"), &[("media", "Custom.432x288")]), ["media=w288h432"], "turned"); + assert_eq!(media(Some("Label"), &[("media", "Custom.162x90")]), ["media=Custom.162x90"], "a size the PPD lacks"); + assert_eq!(media(Some("Label"), &[("media", "Envelope #10")]), ["media=Env10"], "by label"); + assert_eq!(media(Some("Label"), &[("media", "Wide")]), ["media=Wide"], "a keyword it has"); + assert_eq!(media(Some("Label"), &[("media", "na_letter_8.5x11in")]), ["media=Letter"], "a PWG name of a PPD size"); + assert_eq!(media(Some("Label"), &[("media", "iso_a4_210x297mm")]), ["media=iso_a4_210x297mm"], "a PWG name the PPD lacks"); + assert_eq!(media(Some("Label"), &[("InputSlot", "Tray2")]), ["InputSlot=Tray2"], "no media"); + assert_eq!(media(None, &[("media", "Custom.288x432")]), ["media=Custom.288x432"], "the default destination"); + assert_eq!(media(Some("Office"), &[("media", "Custom.288x432")]), ["media=Custom.288x432"], "a queue without the PPD"); + // The last `media` is the one CUPS uses, so that is the one named. + assert_eq!(media(Some("Label"), &[("media", "Letter"), ("MEDIA", "Custom.288x432")]), ["media=Letter", "MEDIA=w288h432"]); +} + +/// A driver that measures its label stock in millimetres, under keywords of its own. +const METRIC_LABEL_PPD: &str = "*PPD-Adobe: \"4.3\"\n\ +*PageSize 102x152/4\" x 6\": \"\"\n\ +*PageSize 57x32/57 x 32 mm: \"\"\n\ +*PageSize 58x32/58 x 32 mm: \"\"\n\ +*PaperDimension 102x152: \"289.13 430.87\"\n\ +*PaperDimension 57x32: \"161.57 90.71\"\n\ +*PaperDimension 58x32: \"164.41 90.71\"\n"; + +#[test] +fn a_job_on_a_standard_size_names_the_ppd_keyword_of_that_size() { + let media = |value: &str| { + let job = Job { printer: Some("Label".into()), options: vec![("media".into(), value.into())], ..Job::default() }; + let job = spool::with_ppd_media(&job, |_| spool::parse_ppd_papers(METRIC_LABEL_PPD)); + job.options.into_iter().map(|(_, v)| v).collect::>() + }; + let label = cups_media(LABEL_PAPER).unwrap_or_default(); + assert_eq!(media(label), ["102x152"], "PdfCraft's 4 × 6 in label on the driver's 102 × 152 mm stock"); + assert_eq!(media("na_index-4x6_4x6in"), ["102x152"], "a PWG name in inches"); + assert_eq!(media("oe_4x6-label_4x6in"), ["102x152"]); + assert_eq!(media("w432h288"), ["102x152"], "turned"); + assert_eq!(media("w162h90"), ["57x32"], "the nearest of two sizes in reach"); + assert_eq!(media("om_small-label_57x32mm"), ["57x32"], "a PWG name in millimetres"); + assert_eq!(media("w288h440"), ["w288h440"], "more than 5 pt off"); + assert_eq!(media("Custom.288x432"), ["Custom.288x432"], "custom points match within half a point or not at all"); + for name in ["w288", "wxh432", "w-288h432", "na_x_4x6", "na_x_axbin", "x_4x6in", "winfhinf"] { + assert_eq!(media(name), [name], "{name} names no size"); + } +} + +#[test] +fn nominal_media_sizes() { + assert_eq!(spool::nominal_media_size("Letter"), Some((612.0, 792.0))); + assert_eq!(spool::nominal_media_size("a4"), Some((595.28, 841.89)), "cups_media's names, in any case"); + assert_eq!(spool::nominal_media_size("w288h432"), Some((288.0, 432.0))); + assert_eq!(spool::nominal_media_size("na_index-4x6_4x6in"), Some((288.0, 432.0))); + let a6 = spool::nominal_media_size("iso_a6_105x148mm").unwrap_or_default(); + assert!((a6.0 - 297.64).abs() < 0.01 && (a6.1 - 419.53).abs() < 0.01, "{a6:?}"); + assert_eq!(spool::nominal_media_size("Roll80mm"), None); + assert_eq!(spool::nominal_media_size("w0h432"), None); +} + #[cfg(all(unix, not(target_arch = "wasm32")))] mod cups_options { use crate::spool; @@ -917,6 +1048,25 @@ mod cups_options { std::fs::remove_dir_all(dir).unwrap(); } + /// `printer_papers` and `printer_default_paper` on CUPS: the queue's PPD papers by label and + /// its `*DefaultPageSize`, instances sharing their queue's PPD; nothing for a queue without + /// one or a destination that isn't a plain name. + #[test] + fn printer_papers_are_read_from_the_queue_ppd() { + let dir = folder(); + std::fs::write(dir.join("Label.ppd"), super::LABEL_PPD).unwrap(); + for destination in ["Label", "Label/draft"] { + let (papers, default) = spool::cups_paper_info_in(destination, &dir); + let names: Vec<&str> = papers.iter().map(|(name, _)| name.as_str()).collect(); + assert_eq!(names, ["US Letter", "4 x 6 in", "Envelope #10", "Wide", "Key"], "{destination}"); + assert_eq!(default, Some(("4 x 6 in".to_string(), (288.0, 432.0))), "{destination}"); + } + for destination in ["Missing", "../Label", "Label/a/b", "Label\\draft", "", ".", "-Label"] { + assert_eq!(spool::cups_paper_info_in(destination, &dir), (Vec::new(), None), "{destination:?}"); + } + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] fn invalid_destinations_are_rejected_before_running_lpoptions() { let dir = folder(); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 79423e76cb..a25e77d178 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -8409,6 +8409,10 @@ evidence = [ "crates/ui-egui/tests/it/print.rs::printer_papers_and_properties_selection", "crates/automation/tests/it/automation.rs::printer_papers_through_tools", "crates/print/src/tests.rs::printer_papers_and_default_paper_api", + "crates/print/src/tests.rs::ppd_papers_come_from_the_ppd", + "crates/print/src/tests.rs::a_job_on_a_ppd_paper_names_the_ppd_keyword", + "crates/print/src/tests.rs::a_job_on_a_standard_size_names_the_ppd_keyword_of_that_size", + "crates/print/src/tests.rs::printer_papers_are_read_from_the_queue_ppd", "crates/print/src/tests.rs::overlapping_paper_discovery_two_sided_and_queue_queries", "crates/ui-egui/tests/it/print.rs::a_custom_paper_prints_at_its_size_and_its_printer_keeps_it", "crates/ui-egui/src/workflows/print_paper.rs::the_paper_list_is_the_standard_sizes_then_the_custom_ones", @@ -8417,7 +8421,7 @@ evidence = [ "crates/ui-egui/tests/it/print_custom_paper_names.rs::a_custom_paper_cannot_take_a_standard_name", "crates/automation/tests/it/automation.rs::doc_print_takes_a_paper_size_of_its_own", ] -notes = "In the Print dialog: Custom… beside Paper adds a named size in the preferences' unit (1 to 200 inches a side), kept with the settings and removable. A printer printed to on a custom paper starts on it next time (#2001). The Page Setup button opens the driver's page setup to choose paper size and orientation. printer_papers enumerates driver-reported papers and default paper headlessly. doc_print takes sizes such as 8.5x14in or 210x297mm or driver papers. Missing: margins." +notes = "In the Print dialog: Custom… beside Paper adds a named size in the preferences' unit (1 to 200 inches a side), kept with the settings and removable. A printer printed to on a custom paper starts on it next time (#2001). The Page Setup button opens the driver's page setup to choose paper size and orientation. printer_papers enumerates driver-reported papers and default paper headlessly. On CUPS they are the queue's PPD papers by the driver's names and its *DefaultPageSize, and a job on one, or on a standard size of one (such as the 4 x 6 in label), is fed it under the PPD's keyword. doc_print takes sizes such as 8.5x14in or 210x297mm or driver papers. Missing: margins." [[feature]] id = "print.print-ready-pdf"