collective

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

diff --git a/crates/print/README.md b/crates/print/README.md--- a/crates/print/README.md+++ b/crates/print/README.md@@ -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 fromdiff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs--- a/crates/print/src/lib.rs+++ b/crates/print/src/lib.rs@@ -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, &current);         options     }     #[cfg(not(all(unix, not(target_arch = "wasm32"))))]diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs--- a/crates/print/src/tests.rs+++ b/crates/print/src/tests.rs@@ -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() };