Browse / github.com/storytold/craft-libs / patch
craft-print: ask macOS for Auto Select when a job names no tray
craft-print: ask macOS for Auto Select when a job names no tray The tray half of storytold/pdfcraft#3752. The paper half is storytold/pdfcraft#4161. ### What this changes CUPS gives a job that names no tray the PPD's default tray. On a queue whose default tray is empty (a Xerox AltaLink C8045 defaulting to an empty Tray 1), the printer holds every job ("Queued job needs resources. Tray 1 is empty.") while CUPS reports it completed, so nothing prints. Preview prints on the same queue because the macOS Print dialog's default, Paper Feed: Auto Select, sends `AP_D_InputSlot` with an empty value, which drops the PPD's default tray. - `lp_args` sends `-o AP_D
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- thesilvjurado (imported from pull request #232, open)
- Base
514db92b772ad533cf77d83f092745eb69e9da40- Size
- +148 −9 in 3 files
- Review
- pending
- Shared
- 2026-10-28
- Id
5cd7baadccd491a2764ad899
How it went
No other operator has reported using it yet.
Diff
raw · crates/print/README.md crates/print/src/lib.rs crates/print/src/tests.rs
@@ -15,6 +15,15 @@ opens Windows printer preferences and submits jobs with copies, collation, duple - **macOS/Linux:** CUPS `lpstat`, `lpoptions` and `lp`. Jobs stream through stdin. Exact-size sheets use `fit-to-page=false`; shrink-to-fit uses IPP `print-scaling=auto-fit`.+- **macOS paper tray:** a job whose options name no `InputSlot` also sends `AP_D_InputSlot=`,+ the system Print dialog's Paper Feed: Auto Select. Without it CUPS uses the PPD's default+ tray, and a printer whose default tray is empty holds the job while CUPS reports it completed+ (storytold/pdfcraft#3752). A tray the job names wins, as does one in the user's `lpoptions`+ defaults, which `lp` adds to every job. `apply_queue_defaults` puts the same choice in the+ driver's tray option: `AUTO_SELECT` (an empty keyword, which `lp_args` never sends), labelled+ "Auto Select", first and as the default unless the user's defaults name another tray. Apps+ may show their own translation of the label. Other CUPS builds don't know `AP_D_InputSlot`,+ so Linux jobs are unchanged. - **Windows:** in this process, with no PowerShell, script or other program started, so printing works where App Control for Business or AppLocker enforce script rules. Printers come from `EnumPrinters` (local queues and `\\server\queue` connections) and the default from@@ -139,8 +139,32 @@ pub fn parse_lpstat_e(out: &str) -> Vec<String> { out.lines().map(str::trim).filter(|l| !l.is_empty()).map(str::to_string).collect() } +/// The paper tray's PPD keyword.+const TRAY: &str = "InputSlot";++/// Apple's CUPS option for the macOS Print dialog's Paper Feed. Sent empty (Auto Select), it+/// drops the PPD's default tray from a job that names none, so the printer feeds from a tray+/// holding the job's paper.+const APPLE_TRAY: &str = "AP_D_InputSlot";++/// The paper tray choice that names no tray: the printer picks one holding the job's paper+/// (macOS: Auto Select). It is empty, which is not a PPD keyword, so [`lp_args`] never sends it+/// as an `InputSlot`. See [`apply_queue_defaults`].+pub const AUTO_SELECT: &str = "";+ /// The `lp` arguments for a job. There is no file argument: `lp` reads the job from stdin.+///+/// On macOS a job that names no paper tray asks for Auto Select (`AP_D_InputSlot=`), as the+/// system Print dialog does. Without it CUPS takes the tray from the PPD's default, and a printer+/// whose default tray is empty or holds other paper keeps the job waiting while CUPS reports it+/// completed (storytold/pdfcraft#3752). A tray the job does name wins: given both, CUPS uses the+/// `InputSlot`. pub fn lp_args(job: &Job) -> Vec<String> {+ lp_args_for(job, cfg!(target_os = "macos"))+}++/// [`lp_args`] for Apple's CUPS or another, so tests cover both on any platform.+fn lp_args_for(job: &Job, apple: bool) -> Vec<String> { let mut a = Vec::new(); if let Some(p) = &job.printer { a.extend(["-d".to_string(), p.clone()]);@@ -164,12 +188,18 @@ pub fn lp_args(job: &Job) -> Vec<String> { // PWG 5100.13 §6.2.5: auto-fit fits oversized pages and leaves smaller pages unscaled. Scaling::ShrinkToFit => "print-scaling=auto-fit", });+ let mut tray_named = false; for (key, value) in &job.options { // Driver options are PPD keywords; anything else is not something lp can be given. if is_ppd_keyword(key) && is_ppd_keyword(value) && !HANDLED_OPTIONS.contains(&key.as_str()) {+ // CUPS matches option names without regard to case.+ tray_named |= key.eq_ignore_ascii_case(TRAY) || key.eq_ignore_ascii_case(APPLE_TRAY); a.extend(["-o".to_string(), format!("{key}={value}")]); } }+ if apple && !tray_named {+ a.extend(["-o".to_string(), format!("{APPLE_TRAY}=")]);+ } a } @@ -277,6 +307,38 @@ pub fn parse_lpoptions(out: &str) -> Vec<(String, String)> { .collect() } +/// Make each option's default the queue's current choice (`current`, from [`parse_lpoptions`]:+/// the queue's defaults with the user's own), skipping choices the driver doesn't offer.+///+/// On macOS the paper tray then starts with [`AUTO_SELECT`], as its default, which is what+/// [`lp_args`] sends for a job that names no tray. That is left out when the user's own defaults+/// name a different tray than the driver's default: `lp` adds that tray to every job, and it wins+/// over Auto Select. A user default naming the driver's own default tray can't be told apart from+/// no user default, so it shows as Auto Select although the job still goes to that tray.+pub fn apply_queue_defaults(options: &mut [PrinterOption], current: &[(String, String)]) {+ apply_queue_defaults_for(options, current, cfg!(target_os = "macos"));+}++/// [`apply_queue_defaults`] for Apple's CUPS or another, so tests cover both on any platform.+fn apply_queue_defaults_for(options: &mut [PrinterOption], current: &[(String, String)], apple: bool) {+ // CUPS matches option and choice names without regard to case; the PPD's spelling is kept.+ let driver_tray = options.iter().find(|o| o.key.eq_ignore_ascii_case(TRAY)).map(|o| o.default.clone());+ for (key, value) in current {+ if let Some(o) = options.iter_mut().find(|o| o.key.eq_ignore_ascii_case(key))+ && let Some(choice) = o.choices.iter().find(|(c, _)| c.eq_ignore_ascii_case(value)).map(|(c, _)| c.clone())+ {+ o.default = choice;+ }+ }+ if apple+ && let Some(tray) = options.iter_mut().find(|o| o.key.eq_ignore_ascii_case(TRAY))+ && driver_tray.as_ref() == Some(&tray.default)+ {+ tray.choices.insert(0, (AUTO_SELECT.to_string(), "Auto Select".to_string()));+ tray.default = AUTO_SELECT.to_string();+ }+}+ /// A PPD's text: UTF-8, or ISO Latin-1 (the PPD default) when it isn't valid UTF-8. pub fn ppd_text(bytes: &[u8]) -> String { match std::str::from_utf8(bytes) {@@ -307,13 +369,7 @@ pub fn printer_options(printer: &str) -> Vec<PrinterOption> { .output() .map(|o| parse_lpoptions(&String::from_utf8_lossy(&o.stdout))) .unwrap_or_default();- for (key, value) in current {- if let Some(o) = options.iter_mut().find(|o| o.key == key)- && o.choices.iter().any(|(c, _)| *c == value)- {- o.default = value;- }- }+ apply_queue_defaults(&mut options, ¤t); options } #[cfg(not(all(unix, not(target_arch = "wasm32"))))]@@ -25,7 +25,7 @@ fn spooler_arguments_and_printer_list() { ..Job::default() }; assert_eq!(- lp_args(&job).join(" "),+ spool::lp_args_for(&job, false).join(" "), "-d Office_Laser -n 3 -t memo.pdf -o collate=false -o sides=two-sided-long-edge -o print-color-mode=monochrome -o fit-to-page=false" ); assert_eq!(lp_args(&Job::default())[0], "-n", "no -d: the default printer");@@ -147,7 +147,13 @@ fn print_jobs_reach_lp_on_stdin_never_through_a_shared_temp_folder() { let stdin = std::fs::read(record.join("stdin")); let _ = std::fs::remove_dir_all(&record); assert!(sent.is_ok(), "{sent:?}");- assert_eq!(args.unwrap(), "-n 1 -t memo.pdf -o collate=true -o fit-to-page=false", "no file argument: lp reads the job from stdin");+ // On macOS the job, naming no tray, also asks for Auto Select (storytold/pdfcraft#3752).+ let auto = if cfg!(target_os = "macos") { " -o AP_D_InputSlot=" } else { "" };+ assert_eq!(+ args.unwrap(),+ format!("-n 1 -t memo.pdf -o collate=true -o fit-to-page=false{auto}"),+ "no file argument: lp reads the job from stdin"+ ); assert!(stdin.unwrap() == pdf, "lp receives the whole job on stdin"); let predictable = std::env::temp_dir().join(format!("craft-print-{}", std::process::id())); assert!(!predictable.exists(), "nothing is created at {predictable:?}, a name other local users can predict");@@ -276,6 +282,74 @@ fn printer_option_defaults_and_job_arguments() { assert!(spool::printer_options("../../etc/passwd").is_empty(), "not a queue name"); } +#[test]+fn a_job_naming_no_tray_asks_macos_for_auto_select() {+ // storytold/pdfcraft#3752: CUPS gives a job that names no tray the PPD's default tray. A Xerox+ // queue defaulting to an empty Tray 1 kept every job waiting at the printer ("Tray 1 is+ // empty") while CUPS reported it completed; Preview printed, because the macOS Print dialog's+ // Paper Feed: Auto Select sends `AP_D_InputSlot` empty, which drops the PPD's default tray.+ let auto = "-o AP_D_InputSlot=";+ let args = |options: &[(&str, &str)], apple: bool| {+ let job = Job { options: options.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(), ..Job::default() };+ spool::lp_args_for(&job, apple).join(" ")+ };+ assert_eq!(args(&[], true), format!("-n 1 -t Craft -o collate=true -o fit-to-page=false {auto}"));+ assert!(args(&[("EFMediaType", "Heavy1")], true).ends_with(&format!("-o EFMediaType=Heavy1 {auto}")), "other options are not a tray");+ // A tray the job names is the tray: CUPS would prefer it to Auto Select anyway.+ assert!(args(&[("InputSlot", "Tray3")], true).ends_with("-o fit-to-page=false -o InputSlot=Tray3"));+ assert!(!args(&[("inputslot", "Tray3")], true).contains("AP_D_InputSlot"), "CUPS option names ignore case");+ assert_eq!(args(&[("AP_D_InputSlot", "Tray3")], true).matches("AP_D_InputSlot").count(), 1, "the caller's own is not doubled");+ // Auto Select is no tray at all, and neither is a value lp can't be given.+ for value in [AUTO_SELECT, "Tray 3"] {+ let a = args(&[("InputSlot", value)], true);+ assert!(a.ends_with(auto) && !a.contains("-o InputSlot="), "{value:?}: {a}");+ }+ // `AP_D_InputSlot` is Apple's: other CUPS gets nothing new.+ assert!(!args(&[], false).contains("AP_D_InputSlot"));+ assert!(!args(&[("InputSlot", AUTO_SELECT)], false).contains("InputSlot"));+ assert_eq!(lp_args(&Job::default()), spool::lp_args_for(&Job::default(), cfg!(target_os = "macos")));+}++#[test]+fn macos_offers_auto_select_where_the_driver_would_pick_its_default_tray() {+ // The Fiery PPD's own default tray is AutoSelect; its trays are AutoSelect, ManualFeed, Tray2.+ let options = || spool::parse_ppd(&spool::ppd_text(FIERY_PPD));+ let auto = (AUTO_SELECT.to_string(), "Auto Select".to_string());+ let applied = |current: &str, apple: bool| {+ let mut o = options();+ spool::apply_queue_defaults_for(&mut o, &spool::parse_lpoptions(current), apple);+ o+ };+ // The queue's tray is the driver's default: the job names none and lp_args asks for Auto+ // Select, so the dialog shows that, first, and Tray 2 or the driver's default stays a choice.+ for current in ["InputSlot/Paper tray: *AutoSelect ManualFeed Tray2\n", ""] {+ let o = applied(current, true);+ assert_eq!((o[0].default.as_str(), o[0].choices.first()), (AUTO_SELECT, Some(&auto)), "{current:?}");+ assert_eq!(o[0].choices.len(), 4);+ }+ // The user's own default tray (`lpoptions -o InputSlot=Tray2`): lp adds it to every job and it+ // beats Auto Select, so Auto Select would be a choice that does nothing.+ let o = applied("InputSlot/Paper tray: AutoSelect *Tray2 ManualFeed\nEFRaster/Print queue action: *Hold False\n", true);+ assert_eq!(o[0].default, "Tray2");+ assert!(!o[0].choices.contains(&auto));+ assert_eq!(o[2].default, "Hold", "the other options take the queue's defaults as before");+ // A current choice the driver doesn't offer is skipped, so the driver's default stands.+ assert_eq!(applied("InputSlot/Paper tray: *Tray9\n", true)[0].default, AUTO_SELECT);+ // CUPS reads option and choice names without regard to case: a user default spelled otherwise+ // is still Tray 2, kept in the driver's spelling, and still beats Auto Select.+ let o = applied("inputslot/Paper tray: AutoSelect *tray2\nefraster/Print queue action: *HOLD\n", true);+ assert_eq!((o[0].default.as_str(), o[0].choices.contains(&auto), o[2].default.as_str()), ("Tray2", false, "Hold"));+ // Other platforms: the queue's defaults, nothing more.+ assert_eq!(applied("", false)[0], options()[0]);+ assert_eq!(applied("InputSlot/Paper tray: AutoSelect *Tray2\n", false)[0].default, "Tray2");+ // Only the tray gets it, and a driver without one gets none.+ let mut no_tray = spool::parse_ppd(+ "*OpenUI *EFMediaType/Paper type: PickOne\n*DefaultEFMediaType: Plain\n*EFMediaType Plain/Plain: \"\"\n*CloseUI: *EFMediaType\n",+ );+ spool::apply_queue_defaults_for(&mut no_tray, &[], true);+ assert_eq!(no_tray[0].choices, [("Plain".to_string(), "Plain".to_string())]);+}+ #[test] fn command_previews_do_not_require_pdf_files() { let job = Job { printer: Some("Office (east)".into()), copies: 2, ..Job::default() };