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 from
diff --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() };
