diff --git a/crates/automation/src/printing.rs b/crates/automation/src/printing.rs index 5e9d231ddf..013f012489 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -75,9 +75,9 @@ fn resolve_paper(name: Option<&str>, printer: Option<&str>) -> Result<((f64, f64 if let Some(size) = parse_custom_dimensions(p) { return Ok((size, Some(print::paper_media(None, size)))); } - // 4. A size of its own: "8.5x14in", "210x297mm", "10x15cm". It names no media. + // 4. Explicit units request the same stock as point dimensions. if let Some(size) = paper_size(p) { - return Ok((size, None)); + return Ok((size, Some(print::paper_media(None, size)))); } return Err(bad(format!( "unknown paper {p:?}; choose one of {}, a paper from printer_papers, or a size such as 8.5x14in or 210x297mm (1 to 200 inches a side)", @@ -450,9 +450,19 @@ mod tests { assert_eq!(job(json!({}), Some("Custom.300x500pt")).options, [media("Custom.300x500")]); assert!(matches!(resolve_paper(Some("Quarto"), None), Err(ToolError::InvalidArgs(m)) if m.contains("US Letter"))); - // A size of its own is laid out as asked and names no media. - assert_eq!(resolve_paper(Some("8.5x14in"), None).ok(), Some(((612.0, 1008.0), None))); - assert!(job(json!({}), Some("8.5x14in")).options.is_empty()); + // Every accepted dimension syntax requests the same stock from the queue. + for (input, size) in [ + ("8.5x14in", (612.0, 1008.0)), + ("210x297mm", (210.0 * 72.0 / 25.4, 297.0 * 72.0 / 25.4)), + ("10x15cm", (10.0 * 72.0 / 2.54, 15.0 * 72.0 / 2.54)), + ("300x500pt", (300.0, 500.0)), + ] { + let expected = print::paper_media(None, size); + let (resolved_size, resolved_media) = resolve_paper(Some(input), None).unwrap(); + assert!((resolved_size.0 - size.0).abs() < 0.01 && (resolved_size.1 - size.1).abs() < 0.01, "{input}"); + assert_eq!(resolved_media.as_deref(), Some(expected.as_str()), "{input}"); + assert_eq!(job(json!({}), Some(input)).options, [media(&expected)], "{input}"); + } assert_eq!(spool_job(&Args(&json!({})), "default", String::new(), None, Some("Letter"), None, &[]).ok().map(|j| j.printer), Some(None)); assert!(spool_job(&Args(&json!({ "options": { "InputSlot": 3 } })), "Office", String::new(), None, Some("Letter"), None, &[]).is_err()); } diff --git a/crates/print/src/spool.rs b/crates/print/src/spool.rs index a133431f83..f80b349916 100644 --- a/crates/print/src/spool.rs +++ b/crates/print/src/spool.rs @@ -460,12 +460,18 @@ pub type Paper = (String, (f64, f64)); /// Empty when the printer reports none or on platforms where driver capabilities are not queried. #[cfg(windows)] pub fn printer_papers(printer: &str) -> Vec { + // A call into a missing delay-loaded PrintTicket DLL would terminate the process (#2630). + if missing_print_dll().is_some() { + return Vec::new(); + } let _provider = print_ticket_provider(); printer_papers_internal(printer) } #[cfg(windows)] fn printer_papers_internal(printer: &str) -> Vec { + #[cfg(test)] + PRINTER_BACKEND_CALLS.with(|c| c.set(c.get().saturating_add(1))); use winprint::ticket::{FeatureOptionPack as _, FeatureOptionPackWithPredefined as _}; let Ok(devices) = winprint::printer::PrinterDevice::all() else { return Vec::new() }; let Some(device) = devices.into_iter().find(|d| d.name() == printer) else { return Vec::new() }; @@ -517,6 +523,9 @@ pub fn printer_default_paper(printer: &str) -> Option { /// Reads the supported paper sizes and the default paper size configured for `printer`. pub fn printer_paper_info(printer: &str) -> (Vec, Option) { + if missing_print_dll().is_some() { + return (Vec::new(), None); + } #[cfg(windows)] let _provider = print_ticket_provider(); #[cfg(windows)] @@ -543,6 +552,9 @@ pub fn printer_paper_info(printer: &str) -> (Vec, Option) { /// settings (such as tray or quality) are not captured; general driver settings should /// be configured through [`open_printer_preferences`] (Properties…). pub fn show_printer_properties(printer: &str, parent: Option) -> Result, crate::PrintError> { + if let Some(dll) = missing_print_dll() { + return Err(unavailable(dll)); + } #[cfg(windows)] { let devmode = match craft_print::rgb::settings_dialog(printer, None, parent) { diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index 77da1e221b..71cef32119 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -1690,6 +1690,36 @@ fn printing_without_the_print_dlls_is_refused_before_the_spooler() { let _ = std::fs::remove_dir_all(&dir); } +/// #2630 and #6100: paper discovery must respect the same delay-load guard as submission. +/// The nonexistent queue makes a missing guard observable without requiring printer hardware. +#[cfg(windows)] +#[test] +fn paper_detection_without_print_dlls_never_reaches_the_driver() { + use crate::spool::{PRINT_DLLS, PRINTER_BACKEND_CALLS, SYSTEM32_FOR_TEST}; + let dir = std::env::temp_dir().join(format!("pdfcraft-paper-no-dlls-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + for missing in PRINT_DLLS { + SYSTEM32_FOR_TEST.with(|d| *d.borrow_mut() = Some(dir.clone())); + PRINTER_BACKEND_CALLS.with(|c| c.set(0)); + let name = "PdfCraft test: no such printer"; + let papers = crate::spool::printer_papers(name); + let info = crate::spool::printer_paper_info(name); + let default = crate::spool::printer_default_paper(name); + let calls = PRINTER_BACKEND_CALLS.with(|c| c.get()); + // A broken guard must not open a real dialog while this regression fails. + let properties = (calls == 0).then(|| crate::spool::show_printer_properties(name, None)); + SYSTEM32_FOR_TEST.with(|d| *d.borrow_mut() = None); + assert!(papers.is_empty()); + assert_eq!(info, (Vec::new(), None)); + assert_eq!(default, None); + assert_eq!(calls, 0, "paper queries must not reach the backend without {missing}"); + assert!(matches!(properties, Some(Err(crate::PrintError::Spool(m))) if m.contains(missing))); + std::fs::write(dir.join(missing), b"").unwrap(); + } + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn printer_papers_and_default_paper_api() { // Nonexistent printer returns empty diff --git a/crates/ui-egui/src/workflows/print_paper.rs b/crates/ui-egui/src/workflows/print_paper.rs index 70bd2ecd22..b84d7bd78b 100644 --- a/crates/ui-egui/src/workflows/print_paper.rs +++ b/crates/ui-egui/src/workflows/print_paper.rs @@ -107,6 +107,8 @@ impl PrintDraft { self.custom_papers.remove(0); } self.custom_papers.push(paper); + // Add is an explicit choice too: an outstanding driver read must not replace it. + self.paper_overridden = true; self.paper = self.papers.len() + self.custom_papers.len() - 1; } diff --git a/crates/ui-egui/src/workflows/print_ui/draft.rs b/crates/ui-egui/src/workflows/print_ui/draft.rs index 5c6d87313f..8099f098ad 100644 --- a/crates/ui-egui/src/workflows/print_ui/draft.rs +++ b/crates/ui-egui/src/workflows/print_ui/draft.rs @@ -540,7 +540,8 @@ impl PrintDraft { } .into_iter() // The sheet the PDF was laid out on, not the queue's default paper. - .chain(Some(print::paper_media_option(Some(&self.paper_name()), self.paper_size()))) + // A user's custom label is not a keyword from the driver's media catalog. + .chain(Some(print::paper_media_option((!self.paper_is_custom()).then(|| self.paper_name()).as_deref(), self.paper_size()))) .collect(), } } diff --git a/crates/ui-egui/tests/it/print.rs b/crates/ui-egui/tests/it/print.rs index 20f617c157..a6e10f8277 100644 --- a/crates/ui-egui/tests/it/print.rs +++ b/crates/ui-egui/tests/it/print.rs @@ -1538,6 +1538,68 @@ fn settle(h: &mut Harness<'static, PdfCraftApp>) { h.run_steps(8); } +/// A paper explicitly added while discovery is pending stays selected after its reply and reopen. +#[test] +fn adding_custom_paper_overrides_pending_driver_default() { + use pdfcraft_engine::print::spool::Printer; + use std::sync::{Arc, Mutex, mpsc}; + let mut h = harness(); + let (release_tx, release_rx) = mpsc::channel(); + let (started_tx, started_rx) = mpsc::channel(); + let first_read = Arc::new(Mutex::new(Some(release_rx))); + h.state_mut().printer_source = + Some(Arc::new(|| vec![Printer { name: "Labeller".into(), default: true, two_sided: None, description: String::new() }])); + h.state_mut().printer_papers_source = Some(Arc::new(move |_| { + let pending = first_read.lock().unwrap().take(); + if let Some(rx) = pending { + let _ = started_tx.send(()); + let _ = rx.recv(); + } + vec![("Driver label".into(), (288.0, 432.0))] + })); + h.state_mut().default_paper_source = Some(Arc::new(|_| Some(("Driver label".into(), (288.0, 432.0))))); + assert!(h.state_mut().execute("print.dialog")); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + loop { + h.run_steps(1); + if started_rx.try_recv().is_ok() { + break; + } + assert!(std::time::Instant::now() < deadline, "paper discovery started"); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + h.get_by_label("Custom…").click(); + h.run_steps(2); + let units = h.state().print_draft.units.per_point(); + h.state_mut().print_draft.custom_new = Some(("Card".into(), format!("{}", 300.0 * units), format!("{}", 500.0 * units))); + h.run_steps(2); + h.get_by_label("Add").click(); + h.run_steps(2); + assert!(h.state().print_draft.paper_overridden, "Add is an explicit paper choice"); + assert!(!h.state().print_draft.papers_loading(), "the explicit choice need not wait for detection"); + release_tx.send(()).unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while h.state().print_draft.papers_loading.is_some() { + assert!(std::time::Instant::now() < deadline, "paper discovery completed"); + h.run_steps(1); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert_eq!(h.state().print_draft.paper_name(), "Card"); + let (w, h_pts) = h.state().print_draft.paper_size(); + assert!((w - 300.0).abs() < 0.01 && (h_pts - 500.0).abs() < 0.01); + h.state_mut().open_print(); + settle(&mut h); + assert_eq!(h.state().print_draft.paper_name(), "Card", "reopening keeps the explicit choice"); +} + +/// A name entered by the user is not a keyword from the printer's media catalog. +#[test] +fn a_named_custom_paper_requests_its_dimensions_from_the_spooler() { + let mut d = pdfcraft_ui_egui::PrintDraft::default(); + d.add_custom_paper(serde_json::from_value(serde_json::json!({"name": "Card", "width": 300.0, "height": 500.0})).unwrap()); + assert_eq!(d.job("card.pdf").options, [("media".to_string(), "Custom.300x500".to_string())]); +} + /// Page setup: a paper size of one's own joins the Paper list (Custom…) and prints at its size. /// The printer it was printed on keeps it (#2001) by name, across a restart, and Remove takes it /// off the list; the printer's setup then leaves the paper as it is.