fix(print): guard paper discovery and retain explicit custom stock

This commit is contained in:
FredApps committed 2026-11-10 16:42:49 +01:00
1 parent de24a4ddba
commit 257c026c8a
6 files changed
+123 -6

No files matched your search

+15 -5
View File
@@ -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());
}
+12
View File
@@ -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<Paper> {
// 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<Paper> {
#[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<Paper> {
/// Reads the supported paper sizes and the default paper size configured for `printer`.
pub fn printer_paper_info(printer: &str) -> (Vec<Paper>, Option<Paper>) {
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<Paper>, Option<Paper>) {
/// 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<isize>) -> Result<Option<Paper>, 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) {
+30
View File
@@ -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
@@ -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;
}
@@ -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(),
}
}
+62
View File
@@ -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.