From 2b2902194bf977c5a08625a9765cf7b851de93d2 Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Tue, 3 Nov 2026 10:18:01 +0530 Subject: [PATCH 01/19] fix(print): auto-detect printer paper size and add page setup sync (#5861) --- Cargo.lock | 1 + crates/print/Cargo.toml | 3 + crates/print/src/lib.rs | 1 + crates/print/src/spool.rs | 88 +++++++++++++++++++ crates/print/src/tests.rs | 22 +++++ crates/ui-egui/src/app/prefs.rs | 20 +++-- crates/ui-egui/src/workflows/print_ui.rs | 8 ++ crates/ui-egui/src/workflows/print_ui/body.rs | 49 ++++++++--- .../ui-egui/src/workflows/print_ui/draft.rs | 86 +++++++++++++++++- crates/ui-egui/tests/it/print.rs | 42 +++++++++ 10 files changed, 301 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c46eac4790..a8326d98aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4572,6 +4572,7 @@ dependencies = [ "pdfcraft-model", "pdfcraft-render", "thiserror 2.0.21", + "winprint", ] [[package]] diff --git a/crates/print/Cargo.toml b/crates/print/Cargo.toml index 6ffa60b813..7dd4ae9e69 100644 --- a/crates/print/Cargo.toml +++ b/crates/print/Cargo.toml @@ -15,5 +15,8 @@ pdfcraft-content = { workspace = true } pdfcraft-model = { workspace = true } thiserror = { workspace = true } +[target.'cfg(windows)'.dependencies] +winprint = { git = "https://github.com/storytold/winprint.rs", rev = "5f80b9baf6b58937c401656634e42d33f00ddf49" } + [dev-dependencies] pdfcraft-render = { workspace = true } diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index 067112b2e7..c9514d083a 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -43,3 +43,4 @@ pub use settings::{ #[cfg(test)] use sheets::text_direction; pub use sheets::{LABEL_MARGIN, MARGIN, Placement, Sheet, layout, matching_paper, text_reads_up}; +pub use spool::{open_printer_preferences, printer_default_paper, printer_papers, show_printer_properties}; diff --git a/crates/print/src/spool.rs b/crates/print/src/spool.rs index 5ce3d9736b..dd9b7f7172 100644 --- a/crates/print/src/spool.rs +++ b/crates/print/src/spool.rs @@ -157,6 +157,94 @@ pub fn open_printer_preferences(printer: &str) -> Result<(), crate::PrintError> craft_print::open_printer_preferences(printer).map_err(|e| crate::PrintError::Spool(e.to_string())) } +/// A named paper with dimensions in points `(width, height)`. +pub type Paper = (String, (f64, f64)); + +/// The paper sizes supported by `printer` as reported by the system / printer driver. +/// Empty when the printer reports none or on platforms where driver capabilities are not queried. +#[cfg(windows)] +pub fn printer_papers(printer: &str) -> Vec { + 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() }; + let Ok(capabilities) = winprint::ticket::PrintCapabilities::fetch(&device) else { return Vec::new() }; + let mut papers = Vec::new(); + for media in capabilities.page_media_sizes() { + let size = media.size(); + let w_pts = (size.width_in_micron() as f64) * 72.0 / 25_400.0; + let h_pts = (size.height_in_micron() as f64) * 72.0 / 25_400.0; + if w_pts > 0.0 && h_pts > 0.0 { + let name = media.display_name().map(str::to_string).unwrap_or_else(|| { + if let Some(predefined) = media.as_predefined_name() { format!("{predefined:?}") } else { format!("{w_pts:.1} x {h_pts:.1} pt") } + }); + let w_pts = (w_pts * 100.0).round() / 100.0; + let h_pts = (h_pts * 100.0).round() / 100.0; + if !papers.iter().any(|(n, (w, h)): &(String, (f64, f64))| n == &name && (*w - w_pts).abs() < 0.1 && (*h - h_pts).abs() < 0.1) { + papers.push((name, (w_pts, h_pts))); + } + } + } + papers +} + +#[cfg(not(windows))] +pub fn printer_papers(_printer: &str) -> Vec { + Vec::new() +} + +/// The default paper size configured for `printer` in the printer properties / DEVMODE. +/// Returns `Some((name, (width, height)))` if readable. +#[cfg(windows)] +pub fn printer_default_paper(printer: &str) -> Option { + let settings = craft_print::rgb::default_settings(printer).ok()?; + let page = craft_print::rgb::page(printer, Some(&settings)).ok()?; + let w_pts = (page.paper.0 * 100.0).round() / 100.0; + let h_pts = (page.paper.1 * 100.0).round() / 100.0; + let papers = printer_papers(printer); + let name = papers + .iter() + .find(|(_, (w, h))| ((*w - w_pts).abs() <= 1.0 && (*h - h_pts).abs() <= 1.0) || ((*w - h_pts).abs() <= 1.0 && (*h - w_pts).abs() <= 1.0)) + .map(|(n, _)| n.clone()) + .unwrap_or_default(); + Some((name, (w_pts, h_pts))) +} + +#[cfg(not(windows))] +pub fn printer_default_paper(_printer: &str) -> Option { + None +} + +/// Shows the printer driver's Print Settings / DocumentProperties dialog (Windows). +/// If the user clicks OK, returns the updated paper size. +pub fn show_printer_properties(printer: &str) -> Result, crate::PrintError> { + #[cfg(windows)] + { + let devmode = match craft_print::rgb::settings_dialog(printer, None, None) { + Ok(Some(dm)) => dm, + Ok(None) => return Ok(None), + Err(_) => { + open_printer_preferences(printer)?; + return Ok(None); + } + }; + let page = craft_print::rgb::page(printer, Some(&devmode)).map_err(|e| crate::PrintError::Spool(e.to_string()))?; + let w_pts = (page.paper.0 * 100.0).round() / 100.0; + let h_pts = (page.paper.1 * 100.0).round() / 100.0; + let papers = printer_papers(printer); + let name = papers + .iter() + .find(|(_, (w, h))| ((*w - w_pts).abs() <= 1.0 && (*h - h_pts).abs() <= 1.0) || ((*w - h_pts).abs() <= 1.0 && (*h - w_pts).abs() <= 1.0)) + .map(|(n, _)| n.clone()) + .unwrap_or_default(); + Ok(Some((name, (w_pts, h_pts)))) + } + #[cfg(not(windows))] + { + let _ = printer; + Ok(None) + } +} + /// The PPD language codes (`*cupsLanguages`, the `ll` of `*ll.Translation`) to try for a UI /// language code, most specific first: `pt-br` → `pt_BR`, `pt`; `zh-hans` → `zh_CN`, `zh`. // Read only by the CUPS path; the tests exercise it everywhere. diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index 885756e8c6..75e9b0c4cb 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -1144,3 +1144,25 @@ fn contents_pieces_join_with_a_space_across_a_trailing_comment() { let printed_ctrl = impose(&with_newline, &settings(vec![0], Layout::Size(SizeMode::Actual))).unwrap(); assert_eq!(form_strings(printed_ctrl), [b"HIDDEN".to_vec(), b"SHOWN".to_vec()]); } + +#[test] +fn printer_papers_and_default_paper_api() { + // Nonexistent printer returns empty + assert!(crate::spool::printer_papers("Nonexistent Printer 12345").is_empty()); + assert_eq!(crate::spool::printer_default_paper("Nonexistent Printer 12345"), None); + + #[cfg(windows)] + { + // On Windows with installed printers, test detection if any installed + for p in crate::spool::printers() { + let papers = crate::spool::printer_papers(&p.name); + if !papers.is_empty() { + // Every paper has a valid positive size and a non-empty name + for (name, (w, h)) in &papers { + assert!(!name.is_empty()); + assert!(*w > 0.0 && *h > 0.0); + } + } + } + } +} diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index 7eddee0af0..ae98e11316 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -151,7 +151,13 @@ impl PdfCraftApp { v["show_secured_notice"] = serde_json::Value::Bool(self.show_secured_notice); v["organize_zoom"] = serde_json::json!(self.organize_zoom); // Canonical names survive changes to the order of PAPERS and do not depend on UI language. - v["print_paper"] = serde_json::json!(pdfcraft_engine::print::PAPERS.get(self.print_draft.paper).map(|(name, _)| *name)); + v["print_paper"] = serde_json::json!( + self.print_draft + .papers + .get(self.print_draft.paper) + .map(|(name, _)| name.as_str()) + .or_else(|| pdfcraft_engine::print::PAPERS.get(self.print_draft.paper).map(|(name, _)| *name)) + ); v.to_string() } @@ -259,12 +265,12 @@ impl PdfCraftApp { if let Some(on) = v["always_default_view"].as_bool() { self.always_default_view = on; } - if let Some(paper) = v - .get("print_paper") - .and_then(serde_json::Value::as_str) - .and_then(|name| pdfcraft_engine::print::PAPERS.iter().position(|(canonical, _)| *canonical == name)) - { - self.print_draft.paper = paper; + if let Some(name) = v.get("print_paper").and_then(serde_json::Value::as_str) { + if let Some(paper) = self.print_draft.papers.iter().position(|(n, _)| n == name) { + self.print_draft.paper = paper; + } else if let Some(paper) = pdfcraft_engine::print::PAPERS.iter().position(|(canonical, _)| *canonical == name) { + self.print_draft.paper = paper; + } } if let Some(on) = v["highlight_fields"].as_bool() { self.view_defaults.highlight_fields = on; diff --git a/crates/ui-egui/src/workflows/print_ui.rs b/crates/ui-egui/src/workflows/print_ui.rs index 9145256297..96e5fc39e1 100644 --- a/crates/ui-egui/src/workflows/print_ui.rs +++ b/crates/ui-egui/src/workflows/print_ui.rs @@ -171,6 +171,10 @@ impl PdfCraftApp { which, ..keep }; + self.print_draft.update_printer_papers(); + if !picked && self.print_draft.paper == 0 { + self.print_draft.auto_select_default_paper(); + } self.dialog = Some(crate::Dialog::Print); } @@ -313,6 +317,10 @@ impl PdfCraftApp { if !d.printer_picked || !offered(d.printer.as_deref(), &printers) { d.printer_picked = false; d.printer = default_printer(&printers); + d.update_printer_papers(); + if d.paper == 0 { + d.auto_select_default_paper(); + } } d.printers = printers; d.printers_loading = false; diff --git a/crates/ui-egui/src/workflows/print_ui/body.rs b/crates/ui-egui/src/workflows/print_ui/body.rs index d5cdbf16ed..2a140e79d5 100644 --- a/crates/ui-egui/src/workflows/print_ui/body.rs +++ b/crates/ui-egui/src/workflows/print_ui/body.rs @@ -1,7 +1,7 @@ use super::draft::{Handling, PrintDraft, Which}; use super::{ - Binding, BookletSubset, Color32, Content, Orientation, PAPERS, PREVIEW_BOX, PREVIEW_H, PREVIEW_W, PageOrder, Pos2, Rect, SizeMode, Stroke, - Subset, Tokens, pos2, print, printer_label, spool, still_looking, theme, vec2, widgets, + Binding, BookletSubset, Color32, Content, Orientation, PREVIEW_BOX, PREVIEW_H, PREVIEW_W, PageOrder, Pos2, Rect, SizeMode, Stroke, Subset, + Tokens, pos2, print, printer_label, spool, still_looking, theme, vec2, widgets, }; /// Properties… on CUPS: the driver's options for this print, grouped as the driver groups them. @@ -141,7 +141,13 @@ pub(crate) fn body( && let Some(p) = d.printer.clone() { if spool::HAS_PRINTER_PREFERENCES { - d.driver_error = spool::open_printer_preferences(&p).err().map(|e| e.to_string()); + match spool::show_printer_properties(&p) { + Ok(Some((name, size))) => { + d.apply_driver_paper(name, size); + } + Ok(None) => {} + Err(e) => d.driver_error = Some(e.to_string()), + } } else { if d.driver_options.as_ref().is_none_or(|(read_for, _)| *read_for != p) && d.driver_options_loading.as_deref() != Some(p.as_str()) { // Read off the GUI thread (#1818): `poll_print` starts it next frame. @@ -158,6 +164,8 @@ pub(crate) fn body( d.printer_picked = true; d.show_driver_options = false; d.driver_error = None; + d.update_printer_papers(); + d.auto_select_default_paper(); } ui.end_row(); // The spooler answered with no printer at all (#160): say so, rather than @@ -202,15 +210,34 @@ pub(crate) fn body( ui.end_row(); } let paper_label = ui.label(tl!("Paper:")); - let paper_name = PAPERS.get(d.paper).map_or("US Letter", |p| p.0); + let paper_name = d.paper_name().to_string(); let before = d.paper; - craft_ui::ComboBox::from_id_salt("paper").selected_text(tl!(paper_name)).width(260.0).show_ui(ui, |ui| { - for (i, (name, _)) in PAPERS.iter().enumerate() { - ui.selectable_value(&mut d.paper, i, tl!(name)); + ui.horizontal(|ui| { + craft_ui::ComboBox::from_id_salt("paper").selected_text(tl!(&paper_name)).width(180.0).show_ui(ui, |ui| { + for (i, (name, _)) in d.papers.iter().enumerate() { + ui.selectable_value(&mut d.paper, i, tl!(name)); + } + }) + .response + .labelled_by(paper_label.id); + let page_setup_tip = if spool::HAS_PRINTER_PREFERENCES { + tl!("Opens the printer driver's page setup: paper size, orientation and source.") + } else { + tl!("The printer driver's page setup: paper size, orientation and source.") + }; + let page_setup = ui.add_enabled(d.printer.is_some() && !browser, egui::Button::new(tl!("Page Setup…"))).on_hover_text(page_setup_tip); + if page_setup.clicked() + && let Some(p) = d.printer.clone() + { + match spool::show_printer_properties(&p) { + Ok(Some((name, size))) => { + d.apply_driver_paper(name, size); + } + Ok(None) => {} + Err(e) => d.driver_error = Some(e.to_string()), + } } - }) - .response - .labelled_by(paper_label.id); + }); // Label stock gets the label layout (cut, turned upright, fitted). if d.paper != before && d.paper == print::LABEL_PAPER { d.paper_before_label = Some(before); @@ -242,7 +269,7 @@ pub(crate) fn body( if let Some(&source) = sizes.get(d.current_page) { ui.horizontal_wrapped(|ui| { ui.label(crate::i18n::fmt(tl!("PDF page: {size}"), &[("size", &d.units.size(source.0, source.1))])); - let matched = print::matching_paper(source); + let matched = d.matching_paper(source); if ui.add_enabled(matched.is_some(), egui::Button::new(tl!("Match page at 100%"))).clicked() && let Some(paper) = matched { diff --git a/crates/ui-egui/src/workflows/print_ui/draft.rs b/crates/ui-egui/src/workflows/print_ui/draft.rs index be2a22efb3..6a86e0aae1 100644 --- a/crates/ui-egui/src/workflows/print_ui/draft.rs +++ b/crates/ui-egui/src/workflows/print_ui/draft.rs @@ -61,6 +61,8 @@ pub struct PrintDraft { pub paper: usize, /// The paper chosen before Label put label stock in, so leaving Label gives it back (#3564). pub paper_before_label: Option, + /// The list of papers available for selection (standard PAPERS plus any detected from the driver). + pub papers: Vec<(String, (f64, f64))>, /// The sheet shown in the preview (0-based). pub sheet: usize, pub current_page: usize, @@ -120,6 +122,7 @@ impl Default for PrintDraft { dpi: 300.0, paper: 0, paper_before_label: None, + papers: default_papers(), sheet: 0, current_page: 0, driver_options: None, @@ -135,7 +138,88 @@ impl Default for PrintDraft { } } +/// Standard fallback paper presets. +pub fn default_papers() -> Vec<(String, (f64, f64))> { + PAPERS.iter().map(|(n, s)| (n.to_string(), *s)).collect() +} + impl PrintDraft { + pub fn paper_name(&self) -> &str { + self.papers.get(self.paper).map(|p| p.0.as_str()).or_else(|| PAPERS.get(self.paper).map(|p| p.0)).unwrap_or(PAPERS[0].0) + } + + pub fn paper_size(&self) -> (f64, f64) { + self.papers.get(self.paper).map(|p| p.1).or_else(|| PAPERS.get(self.paper).map(|p| p.1)).unwrap_or(PAPERS[0].1) + } + + pub fn matching_paper(&self, size: (f64, f64)) -> Option { + print::matching_paper(size).or_else(|| { + self.papers.iter().position(|(_, p)| { + ((p.0 - size.0).abs() <= 0.5 && (p.1 - size.1).abs() <= 0.5) || ((p.0 - size.1).abs() <= 0.5 && (p.1 - size.0).abs() <= 0.5) + }) + }) + } + + pub fn update_printer_papers(&mut self) { + let mut papers = default_papers(); + if let Some(ref printer) = self.printer { + let detected = spool::printer_papers(printer); + for (name, size) in detected { + let norm = (size.0.min(size.1), size.0.max(size.1)); + if !papers.iter().any(|(n, s)| { + n.eq_ignore_ascii_case(&name) || { + let s_norm = (s.0.min(s.1), s.0.max(s.1)); + (s_norm.0 - norm.0).abs() <= 0.5 && (s_norm.1 - norm.1).abs() <= 0.5 + } + }) { + papers.push((name, size)); + } + } + } + let current_name = self.paper_name().to_string(); + let current_size = self.paper_size(); + self.papers = papers; + if let Some(pos) = self.papers.iter().position(|(n, _)| n == ¤t_name) { + self.paper = pos; + } else if let Some(pos) = self.papers.iter().position(|(_, s)| (s.0 - current_size.0).abs() <= 0.5 && (s.1 - current_size.1).abs() <= 0.5) { + self.paper = pos; + } else { + self.paper = self.paper.min(self.papers.len().saturating_sub(1)); + } + } + + /// Auto-select the printer's configured default paper (from DEVMODE / driver settings). + pub fn auto_select_default_paper(&mut self) { + if let Some(ref printer) = self.printer + && let Some((name, size)) = spool::printer_default_paper(printer) + { + self.apply_driver_paper(name, size); + } + } + + /// Apply paper size and orientation chosen in printer properties or page setup. + pub fn apply_driver_paper(&mut self, name: String, size: (f64, f64)) { + if let Some(pos) = self.matching_paper(size) { + self.paper = pos; + } else if !name.is_empty() { + if let Some(pos) = self.papers.iter().position(|(n, _)| n.eq_ignore_ascii_case(&name)) { + self.paper = pos; + } else { + self.papers.push((name, size)); + self.paper = self.papers.len() - 1; + } + } else if size.0 > 0.0 && size.1 > 0.0 { + let custom_name = format!("{:.1} x {:.1} pt", size.0, size.1); + self.papers.push((custom_name, size)); + self.paper = self.papers.len() - 1; + } + if size.0 > size.1 { + self.orientation = Orientation::Landscape; + } else if size.1 > size.0 { + self.orientation = Orientation::Portrait; + } + } + /// Choose Page Sizing & Handling. Label loads label stock and remembers the paper it /// replaced; leaving Label gives that paper back, unless another one was picked meanwhile. pub fn set_handling(&mut self, handling: Handling) { @@ -192,7 +276,7 @@ impl PrintDraft { }; Ok(print::Settings { pages, - paper: PAPERS[self.paper.min(PAPERS.len() - 1)].1, + paper: self.paper_size(), orientation: self.orientation, layout, content: self.content, diff --git a/crates/ui-egui/tests/it/print.rs b/crates/ui-egui/tests/it/print.rs index 81025e756f..ef3821e234 100644 --- a/crates/ui-egui/tests/it/print.rs +++ b/crates/ui-egui/tests/it/print.rs @@ -913,3 +913,45 @@ fn a_booklet_decides_two_sided_itself() { assert!(h.query_by_role_and_label(Role::ComboBox, "Two-sided:").is_some()); assert_eq!(h.state().print_draft.job("x").duplex, Some(Duplex::LongEdge), "back to the choice"); } + +/// #5861: The print dialog supports all papers detected from the printer driver, matches custom +/// page sizes against them, and imposes on the chosen dimensions. +#[test] +fn printer_papers_and_properties_selection() { + let mut h = harness(); + assert!(h.state_mut().execute("print.dialog")); + h.run_steps(3); + + // Initial state offers standard PAPERS. + assert!(h.state().print_draft.papers.len() >= 21); + assert_eq!(h.state().print_draft.paper_name(), "US Letter"); + + // When the driver offers additional custom papers, they are available in d.papers. + let custom_paper = ("Custom 333x444".to_string(), (333.0, 444.0)); + h.state_mut().print_draft.papers.push(custom_paper.clone()); + let custom_index = h.state().print_draft.papers.len() - 1; + + h.state_mut().print_draft.paper = custom_index; + assert_eq!(h.state().print_draft.paper_name(), "Custom 333x444"); + assert_eq!(h.state().print_draft.paper_size(), (333.0, 444.0)); + + // Matching page at 100% finds the custom paper + assert_eq!(h.state().print_draft.matching_paper((333.0, 444.0)), Some(custom_index)); + + // Imposed settings use the exact dimensions of the detected paper + let settings = h.state().print_draft.settings(1, &["1".into()]).unwrap(); + assert_eq!(settings.paper, (333.0, 444.0)); + + // Page Setup button is present in the dialog + h.get_by_label("Page Setup…"); + + // Applying driver paper adapts paper selection and orientation + h.state_mut().print_draft.apply_driver_paper("4 x 6 in label (101.6 x 152.4 mm)".into(), (288.0, 432.0)); + assert_eq!(h.state().print_draft.paper, pdfcraft_engine::print::LABEL_PAPER); + assert_eq!(h.state().print_draft.orientation, pdfcraft_engine::print::Orientation::Portrait); + + // Landscape driver media adapts orientation to Landscape + h.state_mut().print_draft.apply_driver_paper("Landscape Custom".into(), (500.0, 300.0)); + assert_eq!(h.state().print_draft.orientation, pdfcraft_engine::print::Orientation::Landscape); + assert_eq!(h.state().print_draft.paper_name(), "Landscape Custom"); +} From 022f89cb7deed45047b2384e9c988fc93f45df45 Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Wed, 4 Nov 2026 11:18:19 +0530 Subject: [PATCH 02/19] fix(print): address review feedback on paper detection and page setup --- crates/ui-egui/src/app/prefs.rs | 9 ++++++ crates/ui-egui/src/workflows/print_ui.rs | 4 +-- crates/ui-egui/src/workflows/print_ui/body.rs | 13 ++++++-- .../ui-egui/src/workflows/print_ui/draft.rs | 32 ++++++++++++++----- 4 files changed, 45 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index 94088a090e..489c216162 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -159,6 +159,8 @@ impl PdfCraftApp { .map(|(name, _)| name.as_str()) .or_else(|| pdfcraft_engine::print::PAPERS.get(self.print_draft.paper).map(|(name, _)| *name)) ); + let size = self.print_draft.paper_size(); + v["print_paper_size"] = serde_json::json!([size.0, size.1]); v.to_string() } @@ -277,6 +279,13 @@ impl PdfCraftApp { self.print_draft.paper = paper; } else if let Some(paper) = pdfcraft_engine::print::PAPERS.iter().position(|(canonical, _)| *canonical == name) { self.print_draft.paper = paper; + } else if let Some(size) = v.get("print_paper_size").and_then(|s| { + let w = s.get(0)?.as_f64()?; + let h = s.get(1)?.as_f64()?; + if w >= 72.0 && h >= 72.0 && w.is_finite() && h.is_finite() { Some((w, h)) } else { None } + }) { + self.print_draft.papers.push((name.to_string(), size)); + self.print_draft.paper = self.print_draft.papers.len() - 1; } } if let Some(on) = v["highlight_fields"].as_bool() { diff --git a/crates/ui-egui/src/workflows/print_ui.rs b/crates/ui-egui/src/workflows/print_ui.rs index 96e5fc39e1..262c84077a 100644 --- a/crates/ui-egui/src/workflows/print_ui.rs +++ b/crates/ui-egui/src/workflows/print_ui.rs @@ -172,7 +172,7 @@ impl PdfCraftApp { ..keep }; self.print_draft.update_printer_papers(); - if !picked && self.print_draft.paper == 0 { + if !picked && !self.print_draft.paper_overridden && self.print_draft.paper == 0 { self.print_draft.auto_select_default_paper(); } self.dialog = Some(crate::Dialog::Print); @@ -318,7 +318,7 @@ impl PdfCraftApp { d.printer_picked = false; d.printer = default_printer(&printers); d.update_printer_papers(); - if d.paper == 0 { + if !d.paper_overridden && d.paper == 0 { d.auto_select_default_paper(); } } diff --git a/crates/ui-egui/src/workflows/print_ui/body.rs b/crates/ui-egui/src/workflows/print_ui/body.rs index 2a140e79d5..edff10b9b6 100644 --- a/crates/ui-egui/src/workflows/print_ui/body.rs +++ b/crates/ui-egui/src/workflows/print_ui/body.rs @@ -165,7 +165,9 @@ pub(crate) fn body( d.show_driver_options = false; d.driver_error = None; d.update_printer_papers(); - d.auto_select_default_paper(); + if !d.paper_overridden { + d.auto_select_default_paper(); + } } ui.end_row(); // The spooler answered with no printer at all (#160): say so, rather than @@ -223,9 +225,11 @@ pub(crate) fn body( let page_setup_tip = if spool::HAS_PRINTER_PREFERENCES { tl!("Opens the printer driver's page setup: paper size, orientation and source.") } else { - tl!("The printer driver's page setup: paper size, orientation and source.") + tl!("Page setup dialog is not available on this system.") }; - let page_setup = ui.add_enabled(d.printer.is_some() && !browser, egui::Button::new(tl!("Page Setup…"))).on_hover_text(page_setup_tip); + let page_setup = ui + .add_enabled(d.printer.is_some() && !browser && spool::HAS_PRINTER_PREFERENCES, egui::Button::new(tl!("Page Setup…"))) + .on_hover_text(page_setup_tip); if page_setup.clicked() && let Some(p) = d.printer.clone() { @@ -238,6 +242,9 @@ pub(crate) fn body( } } }); + if d.paper != before { + d.paper_overridden = true; + } // Label stock gets the label layout (cut, turned upright, fitted). if d.paper != before && d.paper == print::LABEL_PAPER { d.paper_before_label = Some(before); diff --git a/crates/ui-egui/src/workflows/print_ui/draft.rs b/crates/ui-egui/src/workflows/print_ui/draft.rs index 6a86e0aae1..47d388122c 100644 --- a/crates/ui-egui/src/workflows/print_ui/draft.rs +++ b/crates/ui-egui/src/workflows/print_ui/draft.rs @@ -61,6 +61,8 @@ pub struct PrintDraft { pub paper: usize, /// The paper chosen before Label put label stock in, so leaving Label gives it back (#3564). pub paper_before_label: Option, + /// Whether the user has explicitly chosen or overridden the paper size. + pub paper_overridden: bool, /// The list of papers available for selection (standard PAPERS plus any detected from the driver). pub papers: Vec<(String, (f64, f64))>, /// The sheet shown in the preview (0-based). @@ -122,6 +124,7 @@ impl Default for PrintDraft { dpi: 300.0, paper: 0, paper_before_label: None, + paper_overridden: false, papers: default_papers(), sheet: 0, current_page: 0, @@ -144,14 +147,17 @@ pub fn default_papers() -> Vec<(String, (f64, f64))> { } impl PrintDraft { + /// The display name of the currently selected paper. pub fn paper_name(&self) -> &str { self.papers.get(self.paper).map(|p| p.0.as_str()).or_else(|| PAPERS.get(self.paper).map(|p| p.0)).unwrap_or(PAPERS[0].0) } + /// The dimensions in points `(width, height)` of the currently selected paper. pub fn paper_size(&self) -> (f64, f64) { self.papers.get(self.paper).map(|p| p.1).or_else(|| PAPERS.get(self.paper).map(|p| p.1)).unwrap_or(PAPERS[0].1) } + /// Find the index of a paper in `self.papers` matching `size`, allowing for orientation. pub fn matching_paper(&self, size: (f64, f64)) -> Option { print::matching_paper(size).or_else(|| { self.papers.iter().position(|(_, p)| { @@ -160,17 +166,16 @@ impl PrintDraft { }) } + /// Refresh `self.papers` with driver-reported media sizes, preserving the current selection. pub fn update_printer_papers(&mut self) { let mut papers = default_papers(); if let Some(ref printer) = self.printer { let detected = spool::printer_papers(printer); for (name, size) in detected { let norm = (size.0.min(size.1), size.0.max(size.1)); - if !papers.iter().any(|(n, s)| { - n.eq_ignore_ascii_case(&name) || { - let s_norm = (s.0.min(s.1), s.0.max(s.1)); - (s_norm.0 - norm.0).abs() <= 0.5 && (s_norm.1 - norm.1).abs() <= 0.5 - } + if !papers.iter().any(|(_, s)| { + let s_norm = (s.0.min(s.1), s.0.max(s.1)); + (s_norm.0 - norm.0).abs() <= 0.5 && (s_norm.1 - norm.1).abs() <= 0.5 }) { papers.push((name, size)); } @@ -184,7 +189,8 @@ impl PrintDraft { } else if let Some(pos) = self.papers.iter().position(|(_, s)| (s.0 - current_size.0).abs() <= 0.5 && (s.1 - current_size.1).abs() <= 0.5) { self.paper = pos; } else { - self.paper = self.paper.min(self.papers.len().saturating_sub(1)); + self.papers.push((current_name, current_size)); + self.paper = self.papers.len() - 1; } } @@ -199,16 +205,26 @@ impl PrintDraft { /// Apply paper size and orientation chosen in printer properties or page setup. pub fn apply_driver_paper(&mut self, name: String, size: (f64, f64)) { + if !(size.0 >= 72.0 && size.1 >= 72.0 && size.0.is_finite() && size.1.is_finite()) { + return; + } + self.paper_overridden = true; if let Some(pos) = self.matching_paper(size) { self.paper = pos; } else if !name.is_empty() { - if let Some(pos) = self.papers.iter().position(|(n, _)| n.eq_ignore_ascii_case(&name)) { + let norm = (size.0.min(size.1), size.0.max(size.1)); + if let Some(pos) = self.papers.iter().position(|(n, s)| { + n.eq_ignore_ascii_case(&name) && { + let s_norm = (s.0.min(s.1), s.0.max(s.1)); + (s_norm.0 - norm.0).abs() <= 1.0 && (s_norm.1 - norm.1).abs() <= 1.0 + } + }) { self.paper = pos; } else { self.papers.push((name, size)); self.paper = self.papers.len() - 1; } - } else if size.0 > 0.0 && size.1 > 0.0 { + } else { let custom_name = format!("{:.1} x {:.1} pt", size.0, size.1); self.papers.push((custom_name, size)); self.paper = self.papers.len() - 1; From c25ad6b26cb279bb3791126f2b1256d7ac7012ce Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Wed, 4 Nov 2026 11:26:03 +0530 Subject: [PATCH 03/19] fix(print): specify winprint version requirement to satisfy cargo-deny --- crates/print/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/print/Cargo.toml b/crates/print/Cargo.toml index 7dd4ae9e69..69db783571 100644 --- a/crates/print/Cargo.toml +++ b/crates/print/Cargo.toml @@ -16,7 +16,7 @@ pdfcraft-model = { workspace = true } thiserror = { workspace = true } [target.'cfg(windows)'.dependencies] -winprint = { git = "https://github.com/storytold/winprint.rs", rev = "5f80b9baf6b58937c401656634e42d33f00ddf49" } +winprint = { version = "0.2.1", git = "https://github.com/storytold/winprint.rs", rev = "5f80b9baf6b58937c401656634e42d33f00ddf49" } [dev-dependencies] pdfcraft-render = { workspace = true } From d7a50ba475ce4b456480e3b4beb350782f7f205a Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Wed, 4 Nov 2026 11:57:53 +0530 Subject: [PATCH 04/19] fix(print): persist paper override flag and refine driver paper selection --- crates/ui-egui/src/app/prefs.rs | 2 ++ crates/ui-egui/src/workflows/print_ui.rs | 4 ++-- crates/ui-egui/src/workflows/print_ui/body.rs | 8 ++++++-- crates/ui-egui/src/workflows/print_ui/draft.rs | 7 +++++++ crates/ui-egui/tests/it/print.rs | 3 ++- 5 files changed, 19 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index 489c216162..0d7ccf6801 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -161,6 +161,7 @@ impl PdfCraftApp { ); let size = self.print_draft.paper_size(); v["print_paper_size"] = serde_json::json!([size.0, size.1]); + v["print_paper_overridden"] = serde_json::json!(self.print_draft.paper_overridden); v.to_string() } @@ -287,6 +288,7 @@ impl PdfCraftApp { self.print_draft.papers.push((name.to_string(), size)); self.print_draft.paper = self.print_draft.papers.len() - 1; } + self.print_draft.paper_overridden = v.get("print_paper_overridden").and_then(serde_json::Value::as_bool).unwrap_or(true); } if let Some(on) = v["highlight_fields"].as_bool() { self.view_defaults.highlight_fields = on; diff --git a/crates/ui-egui/src/workflows/print_ui.rs b/crates/ui-egui/src/workflows/print_ui.rs index 262c84077a..af71bfac1c 100644 --- a/crates/ui-egui/src/workflows/print_ui.rs +++ b/crates/ui-egui/src/workflows/print_ui.rs @@ -172,7 +172,7 @@ impl PdfCraftApp { ..keep }; self.print_draft.update_printer_papers(); - if !picked && !self.print_draft.paper_overridden && self.print_draft.paper == 0 { + if !picked && !self.print_draft.paper_overridden { self.print_draft.auto_select_default_paper(); } self.dialog = Some(crate::Dialog::Print); @@ -318,7 +318,7 @@ impl PdfCraftApp { d.printer_picked = false; d.printer = default_printer(&printers); d.update_printer_papers(); - if !d.paper_overridden && d.paper == 0 { + if !d.paper_overridden { d.auto_select_default_paper(); } } diff --git a/crates/ui-egui/src/workflows/print_ui/body.rs b/crates/ui-egui/src/workflows/print_ui/body.rs index edff10b9b6..3d093cbd54 100644 --- a/crates/ui-egui/src/workflows/print_ui/body.rs +++ b/crates/ui-egui/src/workflows/print_ui/body.rs @@ -214,10 +214,13 @@ pub(crate) fn body( let paper_label = ui.label(tl!("Paper:")); let paper_name = d.paper_name().to_string(); let before = d.paper; + let mut paper_clicked = false; ui.horizontal(|ui| { craft_ui::ComboBox::from_id_salt("paper").selected_text(tl!(&paper_name)).width(180.0).show_ui(ui, |ui| { for (i, (name, _)) in d.papers.iter().enumerate() { - ui.selectable_value(&mut d.paper, i, tl!(name)); + if ui.selectable_value(&mut d.paper, i, tl!(name)).clicked() { + paper_clicked = true; + } } }) .response @@ -246,7 +249,7 @@ pub(crate) fn body( d.paper_overridden = true; } // Label stock gets the label layout (cut, turned upright, fitted). - if d.paper != before && d.paper == print::LABEL_PAPER { + if paper_clicked && d.paper != before && d.paper == print::LABEL_PAPER { d.paper_before_label = Some(before); d.handling = Handling::Label; d.sheet = 0; @@ -281,6 +284,7 @@ pub(crate) fn body( && let Some(paper) = matched { d.paper = paper; + d.paper_overridden = true; // A paper picked outright: nothing for leaving Label to give back. d.paper_before_label = None; d.handling = Handling::Size; diff --git a/crates/ui-egui/src/workflows/print_ui/draft.rs b/crates/ui-egui/src/workflows/print_ui/draft.rs index 47d388122c..72082626cb 100644 --- a/crates/ui-egui/src/workflows/print_ui/draft.rs +++ b/crates/ui-egui/src/workflows/print_ui/draft.rs @@ -168,10 +168,14 @@ impl PrintDraft { /// Refresh `self.papers` with driver-reported media sizes, preserving the current selection. pub fn update_printer_papers(&mut self) { + let before_label_name = self.paper_before_label.and_then(|idx| self.papers.get(idx).map(|p| p.0.clone())); let mut papers = default_papers(); if let Some(ref printer) = self.printer { let detected = spool::printer_papers(printer); for (name, size) in detected { + if !(size.0 >= 72.0 && size.1 >= 72.0 && size.0.is_finite() && size.1.is_finite()) { + continue; + } let norm = (size.0.min(size.1), size.0.max(size.1)); if !papers.iter().any(|(_, s)| { let s_norm = (s.0.min(s.1), s.0.max(s.1)); @@ -192,6 +196,7 @@ impl PrintDraft { self.papers.push((current_name, current_size)); self.paper = self.papers.len() - 1; } + self.paper_before_label = before_label_name.and_then(|name| self.papers.iter().position(|(n, _)| n == &name)); } /// Auto-select the printer's configured default paper (from DEVMODE / driver settings). @@ -199,7 +204,9 @@ impl PrintDraft { if let Some(ref printer) = self.printer && let Some((name, size)) = spool::printer_default_paper(printer) { + let overridden = self.paper_overridden; self.apply_driver_paper(name, size); + self.paper_overridden = overridden; } } diff --git a/crates/ui-egui/tests/it/print.rs b/crates/ui-egui/tests/it/print.rs index ef3821e234..9aa3779e29 100644 --- a/crates/ui-egui/tests/it/print.rs +++ b/crates/ui-egui/tests/it/print.rs @@ -791,9 +791,10 @@ fn paper_choice_survives_cancel_and_settings_restore() { let saved = h.state().persist(); let json: serde_json::Value = serde_json::from_str(&saved).unwrap(); assert_eq!(json["print_paper"], "A4", "store the canonical name, not its position"); + assert_eq!(json["print_paper_overridden"], true, "explicitly chosen paper records override flag"); let mut app = PdfCraftApp::new(); app.restore(&saved); - let expected = pdfcraft_ui_egui::PrintDraft { paper: a4, ..Default::default() }; + let expected = pdfcraft_ui_egui::PrintDraft { paper: a4, paper_overridden: true, ..Default::default() }; assert_eq!(app.print_draft, expected, "no other print preference becomes persistent"); let mut restarted = harness_with_settings(Some(saved)); assert!(restarted.state_mut().execute("print.dialog")); From 290f775316a61dc105f52b99ada8133885f67f84 Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Wed, 4 Nov 2026 12:43:28 +0530 Subject: [PATCH 05/19] fix(print): refine paper matching, override tracking, and settings restore --- crates/ui-egui/src/app/prefs.rs | 24 +++++++++++++------ crates/ui-egui/src/workflows/print_ui.rs | 2 +- crates/ui-egui/src/workflows/print_ui/body.rs | 2 +- .../ui-egui/src/workflows/print_ui/draft.rs | 17 ++++++++++--- crates/ui-egui/tests/it/print.rs | 9 +++++++ 5 files changed, 42 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index 0d7ccf6801..6d26b665b5 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -276,19 +276,29 @@ impl PdfCraftApp { self.always_default_view = on; } if let Some(name) = v.get("print_paper").and_then(serde_json::Value::as_str) { - if let Some(paper) = self.print_draft.papers.iter().position(|(n, _)| n == name) { - self.print_draft.paper = paper; - } else if let Some(paper) = pdfcraft_engine::print::PAPERS.iter().position(|(canonical, _)| *canonical == name) { - self.print_draft.paper = paper; - } else if let Some(size) = v.get("print_paper_size").and_then(|s| { + let saved_size = v.get("print_paper_size").and_then(|s| { let w = s.get(0)?.as_f64()?; let h = s.get(1)?.as_f64()?; if w >= 72.0 && h >= 72.0 && w.is_finite() && h.is_finite() { Some((w, h)) } else { None } - }) { + }); + let matched_pos = self + .print_draft + .papers + .iter() + .position(|(n, s)| n == name && saved_size.is_none_or(|sz| (s.0 - sz.0).abs() <= 1.0 && (s.1 - sz.1).abs() <= 1.0)) + .or_else(|| { + pdfcraft_engine::print::PAPERS.iter().position(|(canonical, s)| { + *canonical == name && saved_size.is_none_or(|sz| (s.0 - sz.0).abs() <= 1.0 && (s.1 - sz.1).abs() <= 1.0) + }) + }); + if let Some(paper) = matched_pos { + self.print_draft.paper = paper; + self.print_draft.paper_overridden = v.get("print_paper_overridden").and_then(serde_json::Value::as_bool).unwrap_or(true); + } else if let Some(size) = saved_size { self.print_draft.papers.push((name.to_string(), size)); self.print_draft.paper = self.print_draft.papers.len() - 1; + self.print_draft.paper_overridden = v.get("print_paper_overridden").and_then(serde_json::Value::as_bool).unwrap_or(true); } - self.print_draft.paper_overridden = v.get("print_paper_overridden").and_then(serde_json::Value::as_bool).unwrap_or(true); } if let Some(on) = v["highlight_fields"].as_bool() { self.view_defaults.highlight_fields = on; diff --git a/crates/ui-egui/src/workflows/print_ui.rs b/crates/ui-egui/src/workflows/print_ui.rs index af71bfac1c..c9952428be 100644 --- a/crates/ui-egui/src/workflows/print_ui.rs +++ b/crates/ui-egui/src/workflows/print_ui.rs @@ -172,7 +172,7 @@ impl PdfCraftApp { ..keep }; self.print_draft.update_printer_papers(); - if !picked && !self.print_draft.paper_overridden { + if !self.print_draft.paper_overridden { self.print_draft.auto_select_default_paper(); } self.dialog = Some(crate::Dialog::Print); diff --git a/crates/ui-egui/src/workflows/print_ui/body.rs b/crates/ui-egui/src/workflows/print_ui/body.rs index 3d093cbd54..dcf90a1fb5 100644 --- a/crates/ui-egui/src/workflows/print_ui/body.rs +++ b/crates/ui-egui/src/workflows/print_ui/body.rs @@ -245,7 +245,7 @@ pub(crate) fn body( } } }); - if d.paper != before { + if paper_clicked || d.paper != before { d.paper_overridden = true; } // Label stock gets the label layout (cut, turned upright, fitted). diff --git a/crates/ui-egui/src/workflows/print_ui/draft.rs b/crates/ui-egui/src/workflows/print_ui/draft.rs index 72082626cb..bafe5dc3a6 100644 --- a/crates/ui-egui/src/workflows/print_ui/draft.rs +++ b/crates/ui-egui/src/workflows/print_ui/draft.rs @@ -168,7 +168,7 @@ impl PrintDraft { /// Refresh `self.papers` with driver-reported media sizes, preserving the current selection. pub fn update_printer_papers(&mut self) { - let before_label_name = self.paper_before_label.and_then(|idx| self.papers.get(idx).map(|p| p.0.clone())); + let before_label = self.paper_before_label.and_then(|idx| self.papers.get(idx).cloned()); let mut papers = default_papers(); if let Some(ref printer) = self.printer { let detected = spool::printer_papers(printer); @@ -188,7 +188,9 @@ impl PrintDraft { let current_name = self.paper_name().to_string(); let current_size = self.paper_size(); self.papers = papers; - if let Some(pos) = self.papers.iter().position(|(n, _)| n == ¤t_name) { + if let Some(pos) = + self.papers.iter().position(|(n, s)| n == ¤t_name && (s.0 - current_size.0).abs() <= 0.5 && (s.1 - current_size.1).abs() <= 0.5) + { self.paper = pos; } else if let Some(pos) = self.papers.iter().position(|(_, s)| (s.0 - current_size.0).abs() <= 0.5 && (s.1 - current_size.1).abs() <= 0.5) { self.paper = pos; @@ -196,7 +198,16 @@ impl PrintDraft { self.papers.push((current_name, current_size)); self.paper = self.papers.len() - 1; } - self.paper_before_label = before_label_name.and_then(|name| self.papers.iter().position(|(n, _)| n == &name)); + self.paper_before_label = before_label.map(|(name, size)| { + if let Some(pos) = self.papers.iter().position(|(n, s)| n == &name && (s.0 - size.0).abs() <= 0.5 && (s.1 - size.1).abs() <= 0.5) { + pos + } else if let Some(pos) = self.papers.iter().position(|(_, s)| (s.0 - size.0).abs() <= 0.5 && (s.1 - size.1).abs() <= 0.5) { + pos + } else { + self.papers.push((name, size)); + self.papers.len() - 1 + } + }); } /// Auto-select the printer's configured default paper (from DEVMODE / driver settings). diff --git a/crates/ui-egui/tests/it/print.rs b/crates/ui-egui/tests/it/print.rs index 9aa3779e29..04a7d67b52 100644 --- a/crates/ui-egui/tests/it/print.rs +++ b/crates/ui-egui/tests/it/print.rs @@ -796,6 +796,15 @@ fn paper_choice_survives_cancel_and_settings_restore() { app.restore(&saved); let expected = pdfcraft_ui_egui::PrintDraft { paper: a4, paper_overridden: true, ..Default::default() }; assert_eq!(app.print_draft, expected, "no other print preference becomes persistent"); + + // Default draft without override round-trips with paper_overridden remaining false. + let default_app = PdfCraftApp::new(); + let default_saved = default_app.persist(); + let default_json: serde_json::Value = serde_json::from_str(&default_saved).unwrap(); + assert_eq!(default_json["print_paper_overridden"], false); + let mut restored_default = PdfCraftApp::new(); + restored_default.restore(&default_saved); + assert!(!restored_default.print_draft.paper_overridden); let mut restarted = harness_with_settings(Some(saved)); assert!(restarted.state_mut().execute("print.dialog")); restarted.run_steps(3); From 04fa9910ca631575a23e54750e718fed29a8c6bf Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Wed, 4 Nov 2026 14:18:52 +0530 Subject: [PATCH 06/19] fix(print): support default-paper injection, clear driver error on retry, and fix legacy settings restore --- crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/app/prefs.rs | 4 +- crates/ui-egui/src/dialogs.rs | 2 +- crates/ui-egui/src/lib.rs | 4 +- crates/ui-egui/src/workflows/print_ui.rs | 12 ++++- crates/ui-egui/src/workflows/print_ui/body.rs | 5 +- .../ui-egui/src/workflows/print_ui/draft.rs | 21 +++++--- crates/ui-egui/tests/it/print.rs | 54 ++++++++++++++++++- 8 files changed, 89 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 6ac8ea8783..8d5eba980c 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -113,6 +113,7 @@ impl PdfCraftApp { updates: updates::Updates::default(), printer_source: None, options_source: None, + default_paper_source: None, print_tasks: print_ui::PrintTasks::default(), palette_open: false, palette_query: String::new(), diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index 6d26b665b5..fc31f2c6e5 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -293,11 +293,11 @@ impl PdfCraftApp { }); if let Some(paper) = matched_pos { self.print_draft.paper = paper; - self.print_draft.paper_overridden = v.get("print_paper_overridden").and_then(serde_json::Value::as_bool).unwrap_or(true); + self.print_draft.paper_overridden = v.get("print_paper_overridden").and_then(serde_json::Value::as_bool).unwrap_or(false); } else if let Some(size) = saved_size { self.print_draft.papers.push((name.to_string(), size)); self.print_draft.paper = self.print_draft.papers.len() - 1; - self.print_draft.paper_overridden = v.get("print_paper_overridden").and_then(serde_json::Value::as_bool).unwrap_or(true); + self.print_draft.paper_overridden = v.get("print_paper_overridden").and_then(serde_json::Value::as_bool).unwrap_or(false); } } if let Some(on) = v["highlight_fields"].as_bool() { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 84c36f41aa..26daead107 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1325,7 +1325,7 @@ fn dialog_print(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> let view = &mut app.views[i]; view.set_print_grayscale(app.print_draft.grayscale); view.queue_print_previews(&rasters); - let (go, cancel) = crate::print_ui::body(ui, &mut app.print_draft, &t, &sizes, &labels, &mut |p| { + let (go, cancel) = crate::print_ui::body(ui, &mut app.print_draft, &t, &sizes, &labels, app.default_paper_source.as_ref(), &mut |p| { view.need_thumbnail(p, true); view.page_preview(p) }); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 643d5e90ff..440628b889 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -58,7 +58,7 @@ mod editing; mod home; mod hotbar; mod panels; -pub use print_ui::{Handling as PrintHandling, OptionsSource, PrintDraft, PrinterSource, Which as PrintWhich, system_printers}; +pub use print_ui::{DefaultPaperSource, Handling as PrintHandling, OptionsSource, PrintDraft, PrinterSource, Which as PrintWhich, system_printers}; pub use redact_ui::{HiddenDraft, PagesDraft as RedactPagesDraft, RedactPrefs, SearchDraft as RedactSearchDraft, SearchMode as RedactSearchMode}; pub mod i18n; pub mod theme; @@ -341,6 +341,8 @@ pub struct PdfCraftApp { pub printer_source: Option, /// Where Properties… reads a printer's driver options: the spooler's unless a test stands in. pub options_source: Option, + /// Where a printer's configured default paper comes from: DEVMODE / driver settings unless a test stands in. + pub default_paper_source: Option, // A browser has no background print work: the type is empty there. #[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub(crate) print_tasks: print_ui::PrintTasks, diff --git a/crates/ui-egui/src/workflows/print_ui.rs b/crates/ui-egui/src/workflows/print_ui.rs index c9952428be..316a5dfad9 100644 --- a/crates/ui-egui/src/workflows/print_ui.rs +++ b/crates/ui-egui/src/workflows/print_ui.rs @@ -33,6 +33,10 @@ pub type PrinterSource = std::sync::Arc Vec + Send + /// UI language) unless a test stands in. Called off the GUI thread. pub type OptionsSource = std::sync::Arc Vec + Send + Sync>; +/// Where a printer's configured default paper comes from: the spooler's DEVMODE / driver settings +/// unless a test stands in. +pub type DefaultPaperSource = std::sync::Arc Option<(String, (f64, f64))> + Send + Sync>; + /// The system spooler's printers, as the desktop app's [`PrinterSource`]. pub fn system_printers() -> PrinterSource { std::sync::Arc::new(spool::printers) @@ -173,11 +177,15 @@ impl PdfCraftApp { }; self.print_draft.update_printer_papers(); if !self.print_draft.paper_overridden { - self.print_draft.auto_select_default_paper(); + self.auto_select_default_paper(); } self.dialog = Some(crate::Dialog::Print); } + pub(crate) fn auto_select_default_paper(&mut self) { + self.print_draft.auto_select_default_paper_with(self.default_paper_source.as_ref()); + } + /// Label printing: find each printed page's printed area (a quick low-resolution render, as /// Fit Visible does) and which way its text runs. Only the pages being printed are read, at /// most [`LABEL_CROP_PAGES`] of them (the rest print whole); read again when they change. @@ -319,7 +327,7 @@ impl PdfCraftApp { d.printer = default_printer(&printers); d.update_printer_papers(); if !d.paper_overridden { - d.auto_select_default_paper(); + d.auto_select_default_paper_with(self.default_paper_source.as_ref()); } } d.printers = printers; diff --git a/crates/ui-egui/src/workflows/print_ui/body.rs b/crates/ui-egui/src/workflows/print_ui/body.rs index dcf90a1fb5..acde60f898 100644 --- a/crates/ui-egui/src/workflows/print_ui/body.rs +++ b/crates/ui-egui/src/workflows/print_ui/body.rs @@ -87,6 +87,7 @@ pub(crate) fn body( t: &Tokens, sizes: &[(f64, f64)], labels: &[String], + default_paper_source: Option<&super::DefaultPaperSource>, thumb: &mut dyn FnMut(usize) -> Option, ) -> (bool, bool) { ui.set_width(820.0); @@ -141,6 +142,7 @@ pub(crate) fn body( && let Some(p) = d.printer.clone() { if spool::HAS_PRINTER_PREFERENCES { + d.driver_error = None; match spool::show_printer_properties(&p) { Ok(Some((name, size))) => { d.apply_driver_paper(name, size); @@ -166,7 +168,7 @@ pub(crate) fn body( d.driver_error = None; d.update_printer_papers(); if !d.paper_overridden { - d.auto_select_default_paper(); + d.auto_select_default_paper_with(default_paper_source); } } ui.end_row(); @@ -236,6 +238,7 @@ pub(crate) fn body( if page_setup.clicked() && let Some(p) = d.printer.clone() { + d.driver_error = None; match spool::show_printer_properties(&p) { Ok(Some((name, size))) => { d.apply_driver_paper(name, size); diff --git a/crates/ui-egui/src/workflows/print_ui/draft.rs b/crates/ui-egui/src/workflows/print_ui/draft.rs index bafe5dc3a6..6d45041bf4 100644 --- a/crates/ui-egui/src/workflows/print_ui/draft.rs +++ b/crates/ui-egui/src/workflows/print_ui/draft.rs @@ -212,12 +212,21 @@ impl PrintDraft { /// Auto-select the printer's configured default paper (from DEVMODE / driver settings). pub fn auto_select_default_paper(&mut self) { - if let Some(ref printer) = self.printer - && let Some((name, size)) = spool::printer_default_paper(printer) - { - let overridden = self.paper_overridden; - self.apply_driver_paper(name, size); - self.paper_overridden = overridden; + self.auto_select_default_paper_with(None); + } + + /// Auto-select the printer's default paper using an optional source (or DEVMODE / driver settings). + pub fn auto_select_default_paper_with(&mut self, source: Option<&super::DefaultPaperSource>) { + if self.handling == Handling::Label { + return; + } + if let Some(ref printer) = self.printer { + let detected = if let Some(source) = source { source(printer) } else { spool::printer_default_paper(printer) }; + if let Some((name, size)) = detected { + let overridden = self.paper_overridden; + self.apply_driver_paper(name, size); + self.paper_overridden = overridden; + } } } diff --git a/crates/ui-egui/tests/it/print.rs b/crates/ui-egui/tests/it/print.rs index 04a7d67b52..62342c55dc 100644 --- a/crates/ui-egui/tests/it/print.rs +++ b/crates/ui-egui/tests/it/print.rs @@ -805,6 +805,19 @@ fn paper_choice_survives_cancel_and_settings_restore() { let mut restored_default = PdfCraftApp::new(); restored_default.restore(&default_saved); assert!(!restored_default.print_draft.paper_overridden); + + // Opening print with a printer default paper selects that paper when paper_overridden is false. + let mut default_h = harness(); + use pdfcraft_engine::print::spool; + use std::sync::Arc; + default_h.state_mut().printer_source = + Some(Arc::new(|| vec![spool::Printer { name: "Office Printer".into(), default: true, two_sided: None, description: String::new() }])); + default_h.state_mut().default_paper_source = + Some(Arc::new(|printer: &str| if printer == "Office Printer" { Some(("A4".into(), (595.28, 841.89))) } else { None })); + assert!(default_h.state_mut().execute("print.dialog")); + default_h.run_steps(3); + assert_eq!(default_h.state().print_draft.paper, a4); + assert!(!default_h.state().print_draft.paper_overridden); let mut restarted = harness_with_settings(Some(saved)); assert!(restarted.state_mut().execute("print.dialog")); restarted.run_steps(3); @@ -832,6 +845,7 @@ fn paper_restore_accepts_canonical_names_and_ignores_invalid_settings() { let mut app = PdfCraftApp::new(); app.restore(&serde_json::json!({"print_paper": name}).to_string()); assert_eq!(app.print_draft.paper, index); + assert!(!app.print_draft.paper_overridden); assert_eq!(serde_json::from_str::(&app.persist()).unwrap()["print_paper"], *name); } for invalid in [ @@ -863,11 +877,49 @@ fn paper_restore_accepts_canonical_names_and_ignores_invalid_settings() { assert_eq!(serde_json::from_str::(&app.persist()).unwrap()["print_paper"], serde_json::Value::Null); } +#[test] +fn legacy_settings_restore_unsets_override_and_open_selects_printer_default() { + use pdfcraft_engine::print::{PAPERS, spool}; + use std::sync::Arc; + let letter = PAPERS.iter().position(|(name, _)| *name == "US Letter").unwrap(); + let a4 = PAPERS.iter().position(|(name, _)| *name == "A4").unwrap(); + let mut h = harness(); + let legacy = serde_json::json!({ + "print_paper": "US Letter", + }) + .to_string(); + h.state_mut().restore(&legacy); + assert_eq!(h.state().print_draft.paper, letter); + assert!(!h.state().print_draft.paper_overridden, "legacy settings restore leaves paper_overridden as false"); + + h.state_mut().printer_source = + Some(Arc::new(|| vec![spool::Printer { name: "Office Printer".into(), default: true, two_sided: None, description: String::new() }])); + h.state_mut().default_paper_source = + Some(Arc::new(|printer: &str| if printer == "Office Printer" { Some(("A4".into(), (595.28, 841.89))) } else { None })); + assert!(h.state_mut().execute("print.dialog")); + h.run_steps(3); + assert_eq!(h.state().print_draft.paper, a4); + assert!(!h.state().print_draft.paper_overridden); +} + +#[test] +fn auto_select_default_paper_leaves_label_stock_unchanged() { + let mut draft = pdfcraft_ui_egui::PrintDraft { + printer: Some("Office Printer".into()), + handling: pdfcraft_ui_egui::PrintHandling::Label, + paper: pdfcraft_engine::print::LABEL_PAPER, + ..Default::default() + }; + let source: pdfcraft_ui_egui::DefaultPaperSource = std::sync::Arc::new(move |_| Some(("A4".into(), (595.28, 841.89)))); + draft.auto_select_default_paper_with(Some(&source)); + assert_eq!(draft.paper, pdfcraft_engine::print::LABEL_PAPER); +} + #[test] fn restored_paper_keeps_preview_and_saved_sheet_dimensions() { use pdfcraft_engine::print::{self, PAPERS}; let a4 = PAPERS.iter().find(|(name, _)| *name == "A4").unwrap().1; - let mut h = harness_with_settings(Some(r#"{"print_paper":"A4"}"#.into())); + let mut h = harness_with_settings(Some(r#"{"print_paper":"A4","print_paper_overridden":true}"#.into())); assert!(h.state_mut().execute("print.dialog")); h.run_steps(3); h.get_by_value("A4"); From 25b6280ac6dad2af052930ee53a6d99a9d8a9d2e Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Sat, 7 Nov 2026 10:30:49 +0530 Subject: [PATCH 07/19] fix(print): read papers in background, restore properties handler, and pass dialog owner --- crates/print/src/lib.rs | 2 +- crates/print/src/spool.rs | 52 ++++---- crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/dialogs.rs | 2 +- crates/ui-egui/src/lib.rs | 7 +- crates/ui-egui/src/workflows/print_ui.rs | 122 ++++++++++++++++-- crates/ui-egui/src/workflows/print_ui/body.rs | 21 ++- .../ui-egui/src/workflows/print_ui/draft.rs | 97 +++++++++----- crates/ui-egui/tests/it/print.rs | 30 ++++- 9 files changed, 247 insertions(+), 87 deletions(-) diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index c9514d083a..d4cecebd7a 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -43,4 +43,4 @@ pub use settings::{ #[cfg(test)] use sheets::text_direction; pub use sheets::{LABEL_MARGIN, MARGIN, Placement, Sheet, layout, matching_paper, text_reads_up}; -pub use spool::{open_printer_preferences, printer_default_paper, printer_papers, show_printer_properties}; +pub use spool::{open_printer_preferences, printer_default_paper, printer_paper_info, printer_papers, show_printer_properties}; diff --git a/crates/print/src/spool.rs b/crates/print/src/spool.rs index 3b7a0d1b2b..04c0e0df96 100644 --- a/crates/print/src/spool.rs +++ b/crates/print/src/spool.rs @@ -240,34 +240,47 @@ pub fn printer_papers(_printer: &str) -> Vec { Vec::new() } -/// The default paper size configured for `printer` in the printer properties / DEVMODE. -/// Returns `Some((name, (width, height)))` if readable. #[cfg(windows)] -pub fn printer_default_paper(printer: &str) -> Option { - let settings = craft_print::rgb::default_settings(printer).ok()?; - let page = craft_print::rgb::page(printer, Some(&settings)).ok()?; - let w_pts = (page.paper.0 * 100.0).round() / 100.0; - let h_pts = (page.paper.1 * 100.0).round() / 100.0; - let papers = printer_papers(printer); +fn resolve_paper_name(papers: &[Paper], paper_pts: (f64, f64)) -> Paper { + let w_pts = (paper_pts.0 * 100.0).round() / 100.0; + let h_pts = (paper_pts.1 * 100.0).round() / 100.0; let name = papers .iter() .find(|(_, (w, h))| ((*w - w_pts).abs() <= 1.0 && (*h - h_pts).abs() <= 1.0) || ((*w - h_pts).abs() <= 1.0 && (*h - w_pts).abs() <= 1.0)) .map(|(n, _)| n.clone()) .unwrap_or_default(); - Some((name, (w_pts, h_pts))) + (name, (w_pts, h_pts)) } -#[cfg(not(windows))] -pub fn printer_default_paper(_printer: &str) -> Option { - None +/// The default paper size configured for `printer` in the printer properties / DEVMODE. +/// Returns `Some((name, (width, height)))` if readable. +pub fn printer_default_paper(printer: &str) -> Option { + printer_paper_info(printer).1 +} + +/// Reads the supported paper sizes and the default paper size configured for `printer`. +pub fn printer_paper_info(printer: &str) -> (Vec, Option) { + let papers = printer_papers(printer); + #[cfg(windows)] + let default_paper = { + let settings = craft_print::rgb::default_settings(printer).ok(); + let page = settings.as_ref().and_then(|s| craft_print::rgb::page(printer, Some(s)).ok()); + page.map(|p| resolve_paper_name(&papers, p.paper)) + }; + #[cfg(not(windows))] + let default_paper = { + let _ = &papers; + None + }; + (papers, default_paper) } /// Shows the printer driver's Print Settings / DocumentProperties dialog (Windows). /// If the user clicks OK, returns the updated paper size. -pub fn show_printer_properties(printer: &str) -> Result, crate::PrintError> { +pub fn show_printer_properties(printer: &str, parent: Option) -> Result, crate::PrintError> { #[cfg(windows)] { - let devmode = match craft_print::rgb::settings_dialog(printer, None, None) { + let devmode = match craft_print::rgb::settings_dialog(printer, None, parent) { Ok(Some(dm)) => dm, Ok(None) => return Ok(None), Err(_) => { @@ -276,19 +289,12 @@ pub fn show_printer_properties(printer: &str) -> Result, crate::Pr } }; let page = craft_print::rgb::page(printer, Some(&devmode)).map_err(|e| crate::PrintError::Spool(e.to_string()))?; - let w_pts = (page.paper.0 * 100.0).round() / 100.0; - let h_pts = (page.paper.1 * 100.0).round() / 100.0; let papers = printer_papers(printer); - let name = papers - .iter() - .find(|(_, (w, h))| ((*w - w_pts).abs() <= 1.0 && (*h - h_pts).abs() <= 1.0) || ((*w - h_pts).abs() <= 1.0 && (*h - w_pts).abs() <= 1.0)) - .map(|(n, _)| n.clone()) - .unwrap_or_default(); - Ok(Some((name, (w_pts, h_pts)))) + Ok(Some(resolve_paper_name(&papers, page.paper))) } #[cfg(not(windows))] { - let _ = printer; + let _ = (printer, parent); Ok(None) } } diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 8d5eba980c..1a48b591c0 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -114,6 +114,7 @@ impl PdfCraftApp { printer_source: None, options_source: None, default_paper_source: None, + printer_papers_source: None, print_tasks: print_ui::PrintTasks::default(), palette_open: false, palette_query: String::new(), diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 26daead107..84c36f41aa 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1325,7 +1325,7 @@ fn dialog_print(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> let view = &mut app.views[i]; view.set_print_grayscale(app.print_draft.grayscale); view.queue_print_previews(&rasters); - let (go, cancel) = crate::print_ui::body(ui, &mut app.print_draft, &t, &sizes, &labels, app.default_paper_source.as_ref(), &mut |p| { + let (go, cancel) = crate::print_ui::body(ui, &mut app.print_draft, &t, &sizes, &labels, &mut |p| { view.need_thumbnail(p, true); view.page_preview(p) }); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 440628b889..c867d775c2 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -58,7 +58,10 @@ mod editing; mod home; mod hotbar; mod panels; -pub use print_ui::{DefaultPaperSource, Handling as PrintHandling, OptionsSource, PrintDraft, PrinterSource, Which as PrintWhich, system_printers}; +pub use print_ui::{ + DefaultPaperSource, Handling as PrintHandling, OptionsSource, PrintDraft, PrinterPapersSource, PrinterSource, Which as PrintWhich, + system_printers, +}; pub use redact_ui::{HiddenDraft, PagesDraft as RedactPagesDraft, RedactPrefs, SearchDraft as RedactSearchDraft, SearchMode as RedactSearchMode}; pub mod i18n; pub mod theme; @@ -343,6 +346,8 @@ pub struct PdfCraftApp { pub options_source: Option, /// Where a printer's configured default paper comes from: DEVMODE / driver settings unless a test stands in. pub default_paper_source: Option, + /// Where a printer's supported paper sizes come from: driver capabilities unless a test stands in. + pub printer_papers_source: Option, // A browser has no background print work: the type is empty there. #[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub(crate) print_tasks: print_ui::PrintTasks, diff --git a/crates/ui-egui/src/workflows/print_ui.rs b/crates/ui-egui/src/workflows/print_ui.rs index 316a5dfad9..8c3ee36772 100644 --- a/crates/ui-egui/src/workflows/print_ui.rs +++ b/crates/ui-egui/src/workflows/print_ui.rs @@ -37,11 +37,20 @@ pub type OptionsSource = std::sync::Arc Vec Option<(String, (f64, f64))> + Send + Sync>; +/// Where a printer's supported paper sizes come from: the spooler driver capabilities +/// unless a test stands in. +pub type PrinterPapersSource = std::sync::Arc Vec + Send + Sync>; + /// The system spooler's printers, as the desktop app's [`PrinterSource`]. pub fn system_printers() -> PrinterSource { std::sync::Arc::new(spool::printers) } +#[cfg(not(target_arch = "wasm32"))] +type PaperInfo = (Vec, Option); +#[cfg(not(target_arch = "wasm32"))] +type PendingPaperInfo = (String, std::sync::mpsc::Receiver); + /// The Print dialog's work in the background: the printer list being read, and the jobs being /// spooled, oldest first. #[derive(Default)] @@ -56,6 +65,12 @@ pub(crate) struct PrintTasks { /// The driver options being read, per printer (`read_options_in_background`). #[cfg(not(target_arch = "wasm32"))] options: Vec<(String, std::sync::mpsc::Receiver>)>, + /// The papers and default paper being read in the background, per printer (`read_papers_in_background`). + #[cfg(not(target_arch = "wasm32"))] + papers: Vec, + /// Cached papers and default paper per printer so switching printers avoids re-querying. + #[cfg(not(target_arch = "wasm32"))] + papers_cache: std::collections::HashMap, /// The browser print being drawn, a sheet a frame (`web_print`). #[cfg(target_arch = "wasm32")] web_job: Option, @@ -168,24 +183,20 @@ impl PdfCraftApp { printers_loading, printer_picked: picked, units: self.units, - printer, + printer: printer.clone(), current_page: current, sheet: 0, selected, which, ..keep }; - self.print_draft.update_printer_papers(); - if !self.print_draft.paper_overridden { - self.auto_select_default_paper(); + #[cfg(not(target_arch = "wasm32"))] + if let Some(p) = printer { + self.read_papers_in_background(p); } self.dialog = Some(crate::Dialog::Print); } - pub(crate) fn auto_select_default_paper(&mut self) { - self.print_draft.auto_select_default_paper_with(self.default_paper_source.as_ref()); - } - /// Label printing: find each printed page's printed area (a quick low-resolution render, as /// Fit Visible does) and which way its text runs. Only the pages being printed are read, at /// most [`LABEL_CROP_PAGES`] of them (the rest print whole); read again when they change. @@ -286,12 +297,93 @@ impl PdfCraftApp { } } + /// Read `printer`'s papers and default paper off the GUI thread (#5861); `poll_print` picks them up. + /// A read already under way for the same printer is left to finish. + #[cfg(not(target_arch = "wasm32"))] + fn read_papers_in_background(&mut self, printer: String) { + if let Some((papers, default_paper)) = self.print_tasks.papers_cache.get(&printer).cloned() { + self.receive_papers(printer, papers, default_paper); + return; + } + if self.print_tasks.papers.iter().any(|(p, _)| *p == printer) { + return; + } + self.print_draft.papers_loading = Some(printer.clone()); + let papers_source = self.printer_papers_source.clone(); + let default_source = self.default_paper_source.clone(); + let (tx, rx) = std::sync::mpsc::channel(); + let wake = WakeOnExit(self.ctx.clone()); + let name = printer.clone(); + let spawned = std::thread::Builder::new().name("printer papers".into()).spawn(move || { + let _wake = wake; + let tx = tx; + let info = match (&papers_source, &default_source) { + (None, None) => spool::printer_paper_info(&name), + (papers_s, def_s) => { + let papers = match papers_s { + Some(s) => s(&name), + None => spool::printer_papers(&name), + }; + let def = match def_s { + Some(s) => s(&name), + None => spool::printer_default_paper(&name), + }; + (papers, def) + } + }; + let _ = tx.send(info); + }); + match spawned { + Ok(_) => self.print_tasks.papers.push((printer, rx)), + Err(_) => { + let d = &mut self.print_draft; + if d.papers_loading.as_deref() == Some(printer.as_str()) { + d.papers_loading = None; + } + } + } + } + + /// The papers and default paper read for `printer` arrived, or the read died. + #[cfg(not(target_arch = "wasm32"))] + fn receive_papers(&mut self, printer: String, papers: Vec, default_paper: Option) { + self.print_tasks.papers_cache.insert(printer.clone(), (papers.clone(), default_paper.clone())); + let d = &mut self.print_draft; + if d.papers_loading.as_deref() == Some(printer.as_str()) { + d.papers_loading = None; + } + if d.printer.as_deref() == Some(printer.as_str()) { + d.apply_printer_papers(papers); + if !d.paper_overridden { + d.apply_default_paper(default_paper); + } + } + } + /// Pick up a printer list, driver options or a spooled job that finished (each frame). pub(crate) fn poll_print(&mut self) { #[cfg(target_arch = "wasm32")] self.poll_web_print(); #[cfg(not(target_arch = "wasm32"))] { + if let Some(printer) = self.print_draft.read_papers_for.take() { + self.read_papers_in_background(printer); + } + let mut arrived_papers = Vec::new(); + self.print_tasks.papers.retain(|(printer, rx)| match rx.try_recv() { + Ok((papers, def)) => { + arrived_papers.push((printer.clone(), papers, def)); + false + } + Err(std::sync::mpsc::TryRecvError::Empty) => true, + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + arrived_papers.push((printer.clone(), Vec::new(), None)); + false + } + }); + for (printer, papers, def) in arrived_papers { + self.receive_papers(printer, papers, def); + } if let Some(printer) = self.print_draft.read_options_for.take() { self.read_options_in_background(printer); } @@ -322,16 +414,18 @@ impl PdfCraftApp { self.print_tasks.printers = None; let d = &mut self.print_draft; // A picked printer that is no longer listed falls back to the default too. - if !d.printer_picked || !offered(d.printer.as_deref(), &printers) { + let changed = if !d.printer_picked || !offered(d.printer.as_deref(), &printers) { d.printer_picked = false; d.printer = default_printer(&printers); - d.update_printer_papers(); - if !d.paper_overridden { - d.auto_select_default_paper_with(self.default_paper_source.as_ref()); - } - } + true + } else { + false + }; d.printers = printers; d.printers_loading = false; + if changed && let Some(p) = d.printer.clone() { + self.read_papers_in_background(p); + } } } let mut finished = Vec::new(); diff --git a/crates/ui-egui/src/workflows/print_ui/body.rs b/crates/ui-egui/src/workflows/print_ui/body.rs index acde60f898..1e83a6df4f 100644 --- a/crates/ui-egui/src/workflows/print_ui/body.rs +++ b/crates/ui-egui/src/workflows/print_ui/body.rs @@ -87,7 +87,6 @@ pub(crate) fn body( t: &Tokens, sizes: &[(f64, f64)], labels: &[String], - default_paper_source: Option<&super::DefaultPaperSource>, thumb: &mut dyn FnMut(usize) -> Option, ) -> (bool, bool) { ui.set_width(820.0); @@ -142,14 +141,7 @@ pub(crate) fn body( && let Some(p) = d.printer.clone() { if spool::HAS_PRINTER_PREFERENCES { - d.driver_error = None; - match spool::show_printer_properties(&p) { - Ok(Some((name, size))) => { - d.apply_driver_paper(name, size); - } - Ok(None) => {} - Err(e) => d.driver_error = Some(e.to_string()), - } + d.driver_error = spool::open_printer_preferences(&p).err().map(|e| e.to_string()); } else { if d.driver_options.as_ref().is_none_or(|(read_for, _)| *read_for != p) && d.driver_options_loading.as_deref() != Some(p.as_str()) { // Read off the GUI thread (#1818): `poll_print` starts it next frame. @@ -166,9 +158,8 @@ pub(crate) fn body( d.printer_picked = true; d.show_driver_options = false; d.driver_error = None; - d.update_printer_papers(); - if !d.paper_overridden { - d.auto_select_default_paper_with(default_paper_source); + if let Some(p) = d.printer.clone() { + d.read_papers_for = Some(p); } } ui.end_row(); @@ -239,7 +230,11 @@ pub(crate) fn body( && let Some(p) = d.printer.clone() { d.driver_error = None; - match spool::show_printer_properties(&p) { + #[cfg(windows)] + let parent = winsafe::HWND::GetActiveWindow().map(|h| h.ptr() as isize); + #[cfg(not(windows))] + let parent = None; + match spool::show_printer_properties(&p, parent) { Ok(Some((name, size))) => { d.apply_driver_paper(name, size); } diff --git a/crates/ui-egui/src/workflows/print_ui/draft.rs b/crates/ui-egui/src/workflows/print_ui/draft.rs index 6d45041bf4..f83c2b3b03 100644 --- a/crates/ui-egui/src/workflows/print_ui/draft.rs +++ b/crates/ui-egui/src/workflows/print_ui/draft.rs @@ -87,6 +87,10 @@ pub struct PrintDraft { pub driver_options_loading: Option, /// Properties… asked for this printer's options; the app starts the read on the next frame. pub read_options_for: Option, + /// The printer whose papers and default paper are being read in the background. + pub papers_loading: Option, + /// A printer change requested reading papers; the app starts the read on the next frame. + pub read_papers_for: Option, } impl Default for PrintDraft { @@ -137,6 +141,8 @@ impl Default for PrintDraft { crop_pages: None, driver_options_loading: None, read_options_for: None, + papers_loading: None, + read_papers_for: None, } } } @@ -168,21 +174,33 @@ impl PrintDraft { /// Refresh `self.papers` with driver-reported media sizes, preserving the current selection. pub fn update_printer_papers(&mut self) { + self.update_printer_papers_with(None); + } + + /// Refresh `self.papers` with driver-reported media sizes from an optional source or the driver. + pub fn update_printer_papers_with(&mut self, source: Option<&super::PrinterPapersSource>) { + let detected = if let Some(ref printer) = self.printer { + if let Some(source) = source { source(printer) } else { spool::printer_papers(printer) } + } else { + Vec::new() + }; + self.apply_printer_papers(detected); + } + + /// Apply driver-reported media sizes to `self.papers`, preserving the current selection. + pub fn apply_printer_papers(&mut self, detected: Vec<(String, (f64, f64))>) { let before_label = self.paper_before_label.and_then(|idx| self.papers.get(idx).cloned()); let mut papers = default_papers(); - if let Some(ref printer) = self.printer { - let detected = spool::printer_papers(printer); - for (name, size) in detected { - if !(size.0 >= 72.0 && size.1 >= 72.0 && size.0.is_finite() && size.1.is_finite()) { - continue; - } - let norm = (size.0.min(size.1), size.0.max(size.1)); - if !papers.iter().any(|(_, s)| { - let s_norm = (s.0.min(s.1), s.0.max(s.1)); - (s_norm.0 - norm.0).abs() <= 0.5 && (s_norm.1 - norm.1).abs() <= 0.5 - }) { - papers.push((name, size)); - } + for (name, size) in detected { + if !(size.0 >= 72.0 && size.1 >= 72.0 && size.0.is_finite() && size.1.is_finite()) { + continue; + } + let norm = (size.0.min(size.1), size.0.max(size.1)); + if !papers.iter().any(|(_, s)| { + let s_norm = (s.0.min(s.1), s.0.max(s.1)); + (s_norm.0 - norm.0).abs() <= 0.5 && (s_norm.1 - norm.1).abs() <= 0.5 + }) { + papers.push((name, size)); } } let current_name = self.paper_name().to_string(); @@ -217,27 +235,34 @@ impl PrintDraft { /// Auto-select the printer's default paper using an optional source (or DEVMODE / driver settings). pub fn auto_select_default_paper_with(&mut self, source: Option<&super::DefaultPaperSource>) { - if self.handling == Handling::Label { - return; - } if let Some(ref printer) = self.printer { let detected = if let Some(source) = source { source(printer) } else { spool::printer_default_paper(printer) }; - if let Some((name, size)) = detected { - let overridden = self.paper_overridden; - self.apply_driver_paper(name, size); - self.paper_overridden = overridden; + self.apply_default_paper(detected); + } + } + + /// Apply the detected default paper. + pub fn apply_default_paper(&mut self, detected: Option<(String, (f64, f64))>) { + if let Some((name, size)) = detected + && let Some(pos) = self.find_or_add_paper(name, size) + { + if self.handling == Handling::Label { + if !self.paper_overridden { + self.paper_before_label = Some(pos); + } + } else { + self.paper = pos; } } } - /// Apply paper size and orientation chosen in printer properties or page setup. - pub fn apply_driver_paper(&mut self, name: String, size: (f64, f64)) { + /// Locate `(name, size)` in `self.papers`, or append it if missing. Returns the paper index. + pub fn find_or_add_paper(&mut self, name: String, size: (f64, f64)) -> Option { if !(size.0 >= 72.0 && size.1 >= 72.0 && size.0.is_finite() && size.1.is_finite()) { - return; + return None; } - self.paper_overridden = true; if let Some(pos) = self.matching_paper(size) { - self.paper = pos; + Some(pos) } else if !name.is_empty() { let norm = (size.0.min(size.1), size.0.max(size.1)); if let Some(pos) = self.papers.iter().position(|(n, s)| { @@ -246,20 +271,28 @@ impl PrintDraft { (s_norm.0 - norm.0).abs() <= 1.0 && (s_norm.1 - norm.1).abs() <= 1.0 } }) { - self.paper = pos; + Some(pos) } else { self.papers.push((name, size)); - self.paper = self.papers.len() - 1; + Some(self.papers.len() - 1) } } else { let custom_name = format!("{:.1} x {:.1} pt", size.0, size.1); self.papers.push((custom_name, size)); - self.paper = self.papers.len() - 1; + Some(self.papers.len() - 1) } - if size.0 > size.1 { - self.orientation = Orientation::Landscape; - } else if size.1 > size.0 { - self.orientation = Orientation::Portrait; + } + + /// Apply paper size and orientation chosen in printer properties or page setup. + pub fn apply_driver_paper(&mut self, name: String, size: (f64, f64)) { + if let Some(pos) = self.find_or_add_paper(name, size) { + self.paper_overridden = true; + self.paper = pos; + if size.0 > size.1 { + self.orientation = Orientation::Landscape; + } else if size.1 > size.0 { + self.orientation = Orientation::Portrait; + } } } diff --git a/crates/ui-egui/tests/it/print.rs b/crates/ui-egui/tests/it/print.rs index 62342c55dc..b23b9b7f9b 100644 --- a/crates/ui-egui/tests/it/print.rs +++ b/crates/ui-egui/tests/it/print.rs @@ -812,10 +812,17 @@ fn paper_choice_survives_cancel_and_settings_restore() { use std::sync::Arc; default_h.state_mut().printer_source = Some(Arc::new(|| vec![spool::Printer { name: "Office Printer".into(), default: true, two_sided: None, description: String::new() }])); + default_h.state_mut().printer_papers_source = Some(Arc::new(|_| vec![("A4".into(), (595.28, 841.89))])); default_h.state_mut().default_paper_source = Some(Arc::new(|printer: &str| if printer == "Office Printer" { Some(("A4".into(), (595.28, 841.89))) } else { None })); assert!(default_h.state_mut().execute("print.dialog")); - default_h.run_steps(3); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while (default_h.state().print_draft.printers_loading || default_h.state().print_draft.papers_loading.is_some()) + && std::time::Instant::now() < deadline + { + default_h.run_steps(1); + std::thread::sleep(std::time::Duration::from_millis(5)); + } assert_eq!(default_h.state().print_draft.paper, a4); assert!(!default_h.state().print_draft.paper_overridden); let mut restarted = harness_with_settings(Some(saved)); @@ -894,10 +901,15 @@ fn legacy_settings_restore_unsets_override_and_open_selects_printer_default() { h.state_mut().printer_source = Some(Arc::new(|| vec![spool::Printer { name: "Office Printer".into(), default: true, two_sided: None, description: String::new() }])); + h.state_mut().printer_papers_source = Some(Arc::new(|_| vec![("A4".into(), (595.28, 841.89))])); h.state_mut().default_paper_source = Some(Arc::new(|printer: &str| if printer == "Office Printer" { Some(("A4".into(), (595.28, 841.89))) } else { None })); assert!(h.state_mut().execute("print.dialog")); - h.run_steps(3); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while (h.state().print_draft.printers_loading || h.state().print_draft.papers_loading.is_some()) && std::time::Instant::now() < deadline { + h.run_steps(1); + std::thread::sleep(std::time::Duration::from_millis(5)); + } assert_eq!(h.state().print_draft.paper, a4); assert!(!h.state().print_draft.paper_overridden); } @@ -910,9 +922,23 @@ fn auto_select_default_paper_leaves_label_stock_unchanged() { paper: pdfcraft_engine::print::LABEL_PAPER, ..Default::default() }; + let a4 = pdfcraft_engine::print::PAPERS.iter().position(|(name, _)| *name == "A4").unwrap(); let source: pdfcraft_ui_egui::DefaultPaperSource = std::sync::Arc::new(move |_| Some(("A4".into(), (595.28, 841.89)))); draft.auto_select_default_paper_with(Some(&source)); assert_eq!(draft.paper, pdfcraft_engine::print::LABEL_PAPER); + assert_eq!(draft.paper_before_label, Some(a4)); +} + +#[test] +fn auto_select_default_paper_preserves_user_orientation() { + let mut draft = pdfcraft_ui_egui::PrintDraft { + printer: Some("Office Printer".into()), + orientation: pdfcraft_engine::print::Orientation::Landscape, + ..Default::default() + }; + let source: pdfcraft_ui_egui::DefaultPaperSource = std::sync::Arc::new(move |_| Some(("A4".into(), (595.28, 841.89)))); + draft.auto_select_default_paper_with(Some(&source)); + assert_eq!(draft.orientation, pdfcraft_engine::print::Orientation::Landscape); } #[test] From f1e2c49d55bfef05ea581627eb96369b8479eb4a Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Sat, 7 Nov 2026 11:20:24 +0530 Subject: [PATCH 08/19] fix(print): apply cached papers immediately, refresh in background, and document functions --- crates/print/src/spool.rs | 4 ++ crates/ui-egui/src/app/prefs.rs | 1 + crates/ui-egui/src/workflows/print_ui.rs | 31 ++++++++++---- .../ui-egui/src/workflows/print_ui/draft.rs | 1 + crates/ui-egui/tests/it/print.rs | 41 +++++++++++++++++++ 5 files changed, 69 insertions(+), 9 deletions(-) diff --git a/crates/print/src/spool.rs b/crates/print/src/spool.rs index 04c0e0df96..3689a317a9 100644 --- a/crates/print/src/spool.rs +++ b/crates/print/src/spool.rs @@ -191,6 +191,7 @@ fn spooled(job: &Job) -> Job { } } +/// Submit a PDF to the system print spooler for the configured `job`. pub fn submit(pdf: &[u8], job: &Job) -> Result { craft_print::submit(pdf, &spooled(job)).map_err(|e| crate::PrintError::Spool(e.to_string())) } @@ -201,6 +202,7 @@ pub fn submit_with_progress(pdf: &[u8], job: &Job, progress: &mut dyn FnMut(usiz craft_print::submit_with_progress(pdf, &spooled(job), progress).map_err(|e| crate::PrintError::Spool(e.to_string())) } +/// Opens the printer driver's native Printing Preferences window (Windows `printui.dll`). pub fn open_printer_preferences(printer: &str) -> Result<(), crate::PrintError> { craft_print::open_printer_preferences(printer).map_err(|e| crate::PrintError::Spool(e.to_string())) } @@ -235,11 +237,13 @@ pub fn printer_papers(printer: &str) -> Vec { papers } +/// The paper sizes supported by `printer` (fallback for non-Windows platforms). #[cfg(not(windows))] pub fn printer_papers(_printer: &str) -> Vec { Vec::new() } +/// Match `paper_pts` against detected driver `papers` within a 1.0 pt tolerance. #[cfg(windows)] fn resolve_paper_name(papers: &[Paper], paper_pts: (f64, f64)) -> Paper { let w_pts = (paper_pts.0 * 100.0).round() / 100.0; diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index fc31f2c6e5..a237cddf8e 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -36,6 +36,7 @@ fn rgb_triple(v: &serde_json::Value) -> Option<[f64; 3]> { } impl PdfCraftApp { + /// Sets the user's interface theme preference (Light, Dark, or System). pub fn set_theme_preference(&mut self, preference: ThemePreference) { self.theme_preference = preference; if let Some(ctx) = self.ctx.clone() { diff --git a/crates/ui-egui/src/workflows/print_ui.rs b/crates/ui-egui/src/workflows/print_ui.rs index 8c3ee36772..c2e8f79d4f 100644 --- a/crates/ui-egui/src/workflows/print_ui.rs +++ b/crates/ui-egui/src/workflows/print_ui.rs @@ -151,6 +151,7 @@ impl Drop for WakeOnExit { } impl PdfCraftApp { + /// Open the Print dialog, preselecting the default printer and reading papers in the background. pub fn open_print(&mut self) { let Some((i, _)) = self.active_ids() else { return }; let (printers, printers_loading) = match self.printer_source.clone() { @@ -241,6 +242,7 @@ impl PdfCraftApp { self.print_draft.crop_pages = Some(pages); } + /// Read the printer list in the background off the GUI thread; `poll_print` picks it up. #[cfg(not(target_arch = "wasm32"))] fn list_printers_in_background(&mut self, source: PrinterSource) { // A list already being read is as fresh as a new one would be. @@ -302,8 +304,13 @@ impl PdfCraftApp { #[cfg(not(target_arch = "wasm32"))] fn read_papers_in_background(&mut self, printer: String) { if let Some((papers, default_paper)) = self.print_tasks.papers_cache.get(&printer).cloned() { - self.receive_papers(printer, papers, default_paper); - return; + let d = &mut self.print_draft; + if d.printer.as_deref() == Some(printer.as_str()) { + d.apply_printer_papers(papers); + if !d.paper_overridden { + d.apply_default_paper(default_paper); + } + } } if self.print_tasks.papers.iter().any(|(p, _)| *p == printer) { return; @@ -347,14 +354,18 @@ impl PdfCraftApp { /// The papers and default paper read for `printer` arrived, or the read died. #[cfg(not(target_arch = "wasm32"))] fn receive_papers(&mut self, printer: String, papers: Vec, default_paper: Option) { - self.print_tasks.papers_cache.insert(printer.clone(), (papers.clone(), default_paper.clone())); + if !papers.is_empty() || default_paper.is_some() { + self.print_tasks.papers_cache.insert(printer.clone(), (papers.clone(), default_paper.clone())); + } let d = &mut self.print_draft; if d.papers_loading.as_deref() == Some(printer.as_str()) { d.papers_loading = None; } if d.printer.as_deref() == Some(printer.as_str()) { - d.apply_printer_papers(papers); - if !d.paper_overridden { + if !papers.is_empty() { + d.apply_printer_papers(papers); + } + if !d.paper_overridden && default_paper.is_some() { d.apply_default_paper(default_paper); } } @@ -414,13 +425,14 @@ impl PdfCraftApp { self.print_tasks.printers = None; let d = &mut self.print_draft; // A picked printer that is no longer listed falls back to the default too. - let changed = if !d.printer_picked || !offered(d.printer.as_deref(), &printers) { + let new_printer = if !d.printer_picked || !offered(d.printer.as_deref(), &printers) { d.printer_picked = false; - d.printer = default_printer(&printers); - true + default_printer(&printers) } else { - false + d.printer.clone() }; + let changed = d.printer != new_printer; + d.printer = new_printer; d.printers = printers; d.printers_loading = false; if changed && let Some(p) = d.printer.clone() { @@ -486,6 +498,7 @@ impl PdfCraftApp { } } + /// Report the outcome of a finished print job to the user. fn report_print_job(&mut self, printer: &str, result: Result) { match result { Ok(msg) if msg.is_empty() => self.notify(crate::i18n::fmt(tl!("Sent to {printer}"), &[("printer", printer)])), diff --git a/crates/ui-egui/src/workflows/print_ui/draft.rs b/crates/ui-egui/src/workflows/print_ui/draft.rs index f83c2b3b03..923be2649d 100644 --- a/crates/ui-egui/src/workflows/print_ui/draft.rs +++ b/crates/ui-egui/src/workflows/print_ui/draft.rs @@ -361,6 +361,7 @@ impl PrintDraft { }) } + /// Whether multiple pages per sheet should be cut and stacked into sorted piles. fn cut_and_stack(&self) -> bool { self.handling == Handling::Multiple && self.order == PageOrder::CutStack } diff --git a/crates/ui-egui/tests/it/print.rs b/crates/ui-egui/tests/it/print.rs index b23b9b7f9b..5298e478d1 100644 --- a/crates/ui-egui/tests/it/print.rs +++ b/crates/ui-egui/tests/it/print.rs @@ -941,6 +941,47 @@ fn auto_select_default_paper_preserves_user_orientation() { assert_eq!(draft.orientation, pdfcraft_engine::print::Orientation::Landscape); } +#[test] +fn cached_papers_apply_immediately_and_refresh_in_background() { + use pdfcraft_engine::print::spool; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + let calls = Arc::new(AtomicUsize::new(0)); + let calls_clone = calls.clone(); + + let mut h = harness(); + h.state_mut().printer_source = + Some(Arc::new(|| vec![spool::Printer { name: "Cached Printer".into(), default: true, two_sided: None, description: String::new() }])); + h.state_mut().printer_papers_source = Some(Arc::new(move |_| { + calls_clone.fetch_add(1, Ordering::SeqCst); + vec![("A4".into(), (595.28, 841.89))] + })); + h.state_mut().default_paper_source = + Some(Arc::new(|printer: &str| if printer == "Cached Printer" { Some(("A4".into(), (595.28, 841.89))) } else { None })); + + assert!(h.state_mut().execute("print.dialog")); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while (h.state().print_draft.printers_loading || h.state().print_draft.papers_loading.is_some()) && std::time::Instant::now() < deadline { + h.run_steps(1); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert_eq!(calls.load(Ordering::SeqCst), 1); + let a4 = h.state().print_draft.paper; + + h.state_mut().dialog = None; + + assert!(h.state_mut().execute("print.dialog")); + assert_eq!(h.state().print_draft.paper, a4); + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while h.state().print_draft.papers_loading.is_some() && std::time::Instant::now() < deadline { + h.run_steps(1); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert_eq!(calls.load(Ordering::SeqCst), 2); +} + #[test] fn restored_paper_keeps_preview_and_saved_sheet_dimensions() { use pdfcraft_engine::print::{self, PAPERS}; From 0a65e0f44cd11f163536839377bb15eb24e55a5a Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Sat, 7 Nov 2026 11:41:33 +0530 Subject: [PATCH 09/19] fix(print): reset previous printer papers to standard presets on printer change --- crates/ui-egui/src/workflows/print_ui.rs | 1 + crates/ui-egui/src/workflows/print_ui/body.rs | 1 + crates/ui-egui/tests/it/print.rs | 39 +++++++++++++++++++ 3 files changed, 41 insertions(+) diff --git a/crates/ui-egui/src/workflows/print_ui.rs b/crates/ui-egui/src/workflows/print_ui.rs index c2e8f79d4f..c5e497c162 100644 --- a/crates/ui-egui/src/workflows/print_ui.rs +++ b/crates/ui-egui/src/workflows/print_ui.rs @@ -436,6 +436,7 @@ impl PdfCraftApp { d.printers = printers; d.printers_loading = false; if changed && let Some(p) = d.printer.clone() { + d.apply_printer_papers(Vec::new()); self.read_papers_in_background(p); } } diff --git a/crates/ui-egui/src/workflows/print_ui/body.rs b/crates/ui-egui/src/workflows/print_ui/body.rs index 1e83a6df4f..6386650682 100644 --- a/crates/ui-egui/src/workflows/print_ui/body.rs +++ b/crates/ui-egui/src/workflows/print_ui/body.rs @@ -159,6 +159,7 @@ pub(crate) fn body( d.show_driver_options = false; d.driver_error = None; if let Some(p) = d.printer.clone() { + d.apply_printer_papers(Vec::new()); d.read_papers_for = Some(p); } } diff --git a/crates/ui-egui/tests/it/print.rs b/crates/ui-egui/tests/it/print.rs index 5298e478d1..b7546bc549 100644 --- a/crates/ui-egui/tests/it/print.rs +++ b/crates/ui-egui/tests/it/print.rs @@ -982,6 +982,45 @@ fn cached_papers_apply_immediately_and_refresh_in_background() { assert_eq!(calls.load(Ordering::SeqCst), 2); } +#[test] +fn uncached_printer_switch_resets_driver_papers_to_standard_presets() { + use pdfcraft_engine::print::spool; + use std::sync::Arc; + + let mut h = harness(); + h.state_mut().printer_source = Some(Arc::new(|| { + vec![ + spool::Printer { name: "Label Printer".into(), default: true, two_sided: None, description: String::new() }, + spool::Printer { name: "Laser Printer".into(), default: false, two_sided: None, description: String::new() }, + ] + })); + h.state_mut().printer_papers_source = Some(Arc::new(|printer: &str| { + if printer == "Label Printer" { + vec![("Special Label 300x500".into(), (300.0, 500.0))] + } else { + vec![("Laser 350x550".into(), (350.0, 550.0))] + } + })); + h.state_mut().default_paper_source = Some(Arc::new(|_| None)); + + assert!(h.state_mut().execute("print.dialog")); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while (h.state().print_draft.printers_loading || h.state().print_draft.papers_loading.is_some()) && std::time::Instant::now() < deadline { + h.run_steps(1); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(h.state().print_draft.papers.iter().any(|(n, _)| n == "Special Label 300x500")); + + // Switch printer via printer-change site: apply_printer_papers(Vec::new()) clears old papers immediately + h.state_mut().print_draft.printer = Some("Laser Printer".into()); + h.state_mut().print_draft.apply_printer_papers(Vec::new()); + assert!( + !h.state().print_draft.papers.iter().any(|(n, _)| n == "Special Label 300x500"), + "Custom label paper from previous printer cleared immediately" + ); + assert!(h.state().print_draft.papers.iter().any(|(n, _)| n == "US Letter"), "Standard presets remain available"); +} + #[test] fn restored_paper_keeps_preview_and_saved_sheet_dimensions() { use pdfcraft_engine::print::{self, PAPERS}; From 12ad3e67f6b164d93532455a58c307e7617f88c2 Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Sat, 7 Nov 2026 14:01:48 +0530 Subject: [PATCH 10/19] fix(print): reset unoverridden custom paper to standard preset on printer change --- crates/ui-egui/src/workflows/print_ui.rs | 7 ++- crates/ui-egui/src/workflows/print_ui/body.rs | 3 +- .../ui-egui/src/workflows/print_ui/draft.rs | 20 ++++++ crates/ui-egui/tests/it/print.rs | 63 ++++++++++++++++++- 4 files changed, 87 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/workflows/print_ui.rs b/crates/ui-egui/src/workflows/print_ui.rs index c5e497c162..c42b889b11 100644 --- a/crates/ui-egui/src/workflows/print_ui.rs +++ b/crates/ui-egui/src/workflows/print_ui.rs @@ -435,9 +435,12 @@ impl PdfCraftApp { d.printer = new_printer; d.printers = printers; d.printers_loading = false; - if changed && let Some(p) = d.printer.clone() { + if changed { + d.reset_unoverridden_custom_paper(); d.apply_printer_papers(Vec::new()); - self.read_papers_in_background(p); + if let Some(p) = d.printer.clone() { + self.read_papers_in_background(p); + } } } } diff --git a/crates/ui-egui/src/workflows/print_ui/body.rs b/crates/ui-egui/src/workflows/print_ui/body.rs index 6386650682..f53e06ae5b 100644 --- a/crates/ui-egui/src/workflows/print_ui/body.rs +++ b/crates/ui-egui/src/workflows/print_ui/body.rs @@ -158,8 +158,9 @@ pub(crate) fn body( d.printer_picked = true; d.show_driver_options = false; d.driver_error = None; + d.reset_unoverridden_custom_paper(); + d.apply_printer_papers(Vec::new()); if let Some(p) = d.printer.clone() { - d.apply_printer_papers(Vec::new()); d.read_papers_for = Some(p); } } diff --git a/crates/ui-egui/src/workflows/print_ui/draft.rs b/crates/ui-egui/src/workflows/print_ui/draft.rs index 923be2649d..c40f482add 100644 --- a/crates/ui-egui/src/workflows/print_ui/draft.rs +++ b/crates/ui-egui/src/workflows/print_ui/draft.rs @@ -172,6 +172,26 @@ impl PrintDraft { }) } + /// Reset an automatically selected custom paper to a standard preset on printer change. + /// + /// If the currently selected paper does not match any standard preset and `paper_overridden` is false, + /// resets `paper` to 0 (the standard default preset) so printer A's custom paper does not + /// carry over when switching to printer B. Also resets `paper_before_label` if it refers to + /// an unoverridden custom paper. Explicit user overrides (`paper_overridden == true`) are preserved. + pub fn reset_unoverridden_custom_paper(&mut self) { + if !self.paper_overridden { + if print::matching_paper(self.paper_size()).is_none() { + self.paper = 0; + } + if let Some(idx) = self.paper_before_label { + let is_custom = self.papers.get(idx).is_none_or(|(_, s)| print::matching_paper(*s).is_none()); + if is_custom { + self.paper_before_label = Some(0); + } + } + } + } + /// Refresh `self.papers` with driver-reported media sizes, preserving the current selection. pub fn update_printer_papers(&mut self) { self.update_printer_papers_with(None); diff --git a/crates/ui-egui/tests/it/print.rs b/crates/ui-egui/tests/it/print.rs index b7546bc549..21f66ef05a 100644 --- a/crates/ui-egui/tests/it/print.rs +++ b/crates/ui-egui/tests/it/print.rs @@ -1001,7 +1001,8 @@ fn uncached_printer_switch_resets_driver_papers_to_standard_presets() { vec![("Laser 350x550".into(), (350.0, 550.0))] } })); - h.state_mut().default_paper_source = Some(Arc::new(|_| None)); + h.state_mut().default_paper_source = + Some(Arc::new(|printer: &str| if printer == "Label Printer" { Some(("Special Label 300x500".into(), (300.0, 500.0))) } else { None })); assert!(h.state_mut().execute("print.dialog")); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); @@ -1009,16 +1010,72 @@ fn uncached_printer_switch_resets_driver_papers_to_standard_presets() { h.run_steps(1); std::thread::sleep(std::time::Duration::from_millis(5)); } - assert!(h.state().print_draft.papers.iter().any(|(n, _)| n == "Special Label 300x500")); + assert_eq!(h.state().print_draft.paper_name(), "Special Label 300x500"); + assert!(!h.state().print_draft.paper_overridden); - // Switch printer via printer-change site: apply_printer_papers(Vec::new()) clears old papers immediately + // Switch printer via printer-change site: reset unoverridden custom paper and apply_printer_papers(Vec::new()) clears old papers immediately h.state_mut().print_draft.printer = Some("Laser Printer".into()); + h.state_mut().print_draft.reset_unoverridden_custom_paper(); h.state_mut().print_draft.apply_printer_papers(Vec::new()); + assert_eq!(h.state().print_draft.paper_name(), "US Letter"); assert!( !h.state().print_draft.papers.iter().any(|(n, _)| n == "Special Label 300x500"), "Custom label paper from previous printer cleared immediately" ); assert!(h.state().print_draft.papers.iter().any(|(n, _)| n == "US Letter"), "Standard presets remain available"); + + // Explicit override is preserved + h.state_mut().print_draft.papers.push(("User Custom 400x600".into(), (400.0, 600.0))); + h.state_mut().print_draft.paper = h.state().print_draft.papers.len() - 1; + h.state_mut().print_draft.paper_overridden = true; + h.state_mut().print_draft.reset_unoverridden_custom_paper(); + assert_eq!(h.state().print_draft.paper_name(), "User Custom 400x600"); +} + +#[test] +fn printer_list_refresh_resets_unoverridden_custom_paper_when_default_printer_changes() { + use pdfcraft_engine::print::spool; + use std::sync::Arc; + + let mut h = harness(); + let initial_printers = vec![ + spool::Printer { name: "Label Printer".into(), default: true, two_sided: None, description: String::new() }, + spool::Printer { name: "Laser Printer".into(), default: false, two_sided: None, description: String::new() }, + ]; + let updated_printers = vec![spool::Printer { name: "Laser Printer".into(), default: true, two_sided: None, description: String::new() }]; + let calls = std::sync::atomic::AtomicUsize::new(0); + let initial_p = initial_printers.clone(); + let updated_p = updated_printers.clone(); + h.state_mut().printer_source = + Some(Arc::new(move || if calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0 { initial_p.clone() } else { updated_p.clone() })); + h.state_mut().printer_papers_source = Some(Arc::new(|printer: &str| { + if printer == "Label Printer" { + vec![("Special Label 300x500".into(), (300.0, 500.0))] + } else { + vec![("Laser 350x550".into(), (350.0, 550.0))] + } + })); + h.state_mut().default_paper_source = + Some(Arc::new(|printer: &str| if printer == "Label Printer" { Some(("Special Label 300x500".into(), (300.0, 500.0))) } else { None })); + + assert!(h.state_mut().execute("print.dialog")); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while (h.state().print_draft.printers_loading || h.state().print_draft.papers_loading.is_some()) && std::time::Instant::now() < deadline { + h.run_steps(1); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert_eq!(h.state().print_draft.paper_name(), "Special Label 300x500"); + + // Trigger background printer list refresh with the updated printers list (where Laser Printer is default) + h.state_mut().open_print(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while (h.state().print_draft.printers_loading || h.state().print_draft.papers_loading.is_some()) && std::time::Instant::now() < deadline { + h.run_steps(1); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + // Laser Printer has no default paper, so paper should be a standard preset ("US Letter"), not Printer A's custom paper + assert_ne!(h.state().print_draft.paper_name(), "Special Label 300x500"); + assert_eq!(h.state().print_draft.paper_name(), "US Letter"); } #[test] From 0c9f8e6e349b63ce0240e68f76b408a0b888b323 Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Sun, 8 Nov 2026 16:08:26 +0530 Subject: [PATCH 11/19] fix(print): defer submission during paper detection, add printer_papers tool, and clarify page setup scope --- crates/automation/src/dispatch.rs | 1 + crates/automation/src/printing.rs | 112 ++++++++++++++---- crates/automation/src/tools.rs | 9 +- crates/automation/tests/it/automation.rs | 19 +++ crates/print/Cargo.toml | 2 +- crates/print/src/spool.rs | 7 +- crates/ui-egui/src/workflows/print_ui.rs | 3 + crates/ui-egui/src/workflows/print_ui/body.rs | 16 ++- .../ui-egui/src/workflows/print_ui/draft.rs | 9 ++ crates/ui-egui/tests/it/print.rs | 89 ++++++++++++++ parity/acrobat-features.toml | 14 ++- 11 files changed, 244 insertions(+), 37 deletions(-) diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index f3c51d22e0..ab4d6310d5 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -719,6 +719,7 @@ impl Automation { "doc_hidden_info" => self.doc_hidden_info(&a)?, "printers" => self.printers()?, "printer_options" => self.printer_options(&a)?, + "printer_papers" => self.printer_papers(&a)?, "link_list" => self.link_list(&a)?, "link_open" => self.link_open(&a)?, "link_add" => self.link_add(&a)?, diff --git a/crates/automation/src/printing.rs b/crates/automation/src/printing.rs index 9a1c1a6b7c..919e8d958a 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -37,31 +37,67 @@ fn bad(m: impl Into) -> ToolError { ToolError::InvalidArgs(m.into()) } -/// `doc_print`'s `paper`, by its name or a short form ("Letter", "ARCH D"), as its `PAPERS` -/// index and size; US Letter when none is given. -fn paper_named(name: Option<&str>) -> Result<(usize, (f64, f64))> { - let mut papers = PAPERS.iter().enumerate(); - let found = match name { - None => papers.next(), - Some(p) => papers.find(|(_, (n, _))| { - n.eq_ignore_ascii_case(p) +/// Parse explicit custom dimensions like "300x500", "300 x 500", "300x500 pt". +fn parse_custom_dimensions(s: &str) -> Option<(f64, f64)> { + let s = s.trim().trim_end_matches("pt").trim_end_matches("points").trim(); + let (w_str, h_str) = s.split_once(['x', 'X', '*'])?; + let w: f64 = w_str.trim().parse().ok()?; + let h: f64 = h_str.trim().parse().ok()?; + if w.is_finite() && h.is_finite() && w >= 72.0 && h >= 72.0 && w <= 14400.0 && h <= 14400.0 { Some((w, h)) } else { None } +} + +/// `doc_print`'s `paper`, by its name, short form, driver-detected paper name, or custom dimensions. +/// Returns `(matching_papers_index, (width, height))`. +fn resolve_paper(name: Option<&str>, printer: Option<&str>) -> Result<(Option, (f64, f64))> { + if let Some(p) = name { + // 1. Match standard PAPERS presets. + for (index, (n, size)) in PAPERS.iter().enumerate() { + if n.eq_ignore_ascii_case(p) || n.replace("US ", "").eq_ignore_ascii_case(p) - || n.split(" (").next().is_some_and(|name| name.eq_ignore_ascii_case(p)) - }), - }; - found.map(|(index, (_, size))| (index, *size)).ok_or_else(|| { - bad(format!( - "unknown paper {:?}; choose one of {}", - name.unwrap_or_default(), + || n.split(" (").next().is_some_and(|label| label.eq_ignore_ascii_case(p)) + { + return Ok((Some(index), *size)); + } + } + // 2. Match detected driver papers for the given printer. + if let Some(printer_name) = printer { + let driver_papers = spool::printer_papers(printer_name); + if let Some((_, size)) = driver_papers.iter().find(|(n, _)| n.eq_ignore_ascii_case(p)) { + return Ok((print::matching_paper(*size), *size)); + } + } + // 3. Match detected driver papers across any known printer. + for pr in spool::printers() { + let driver_papers = spool::printer_papers(&pr.name); + if let Some((_, size)) = driver_papers.iter().find(|(n, _)| n.eq_ignore_ascii_case(p)) { + return Ok((print::matching_paper(*size), *size)); + } + } + // 4. Match custom dimensions ("x"). + if let Some(size) = parse_custom_dimensions(p) { + return Ok((print::matching_paper(size), size)); + } + return Err(bad(format!( + "unknown paper {p:?}; choose one of {}, a paper from printer_papers, or dimensions \"x\"", PAPERS.iter().map(|(name, _)| *name).collect::>().join(", ") - )) - }) + ))); + } + + // When paper is omitted and a printer is given, use the printer's configured default paper if readable. + if let Some(printer_name) = printer + && let Some((_, size)) = spool::printer_default_paper(printer_name) + { + return Ok((print::matching_paper(size), size)); + } + + // Default fallback: US Letter. + Ok((Some(0), PAPERS[0].1)) } /// The spooler job for `doc_print` to `printer`. It feeds the paper the PDF was laid out on /// (`PAPERS[paper]`, as `media` after the driver's `options`) rather than the queue's default /// paper, which a job naming none got (A4 under Letter sheets on the queue in #3752). -fn spool_job(a: &Args, printer: &str, title: String, duplex: Option, paper: usize) -> Result { +fn spool_job(a: &Args, printer: &str, title: String, duplex: Option, paper: Option) -> Result { let options = match a.get("options") { None => Vec::new(), Some(Value::Object(m)) => m @@ -81,7 +117,7 @@ fn spool_job(a: &Args, printer: &str, title: String, duplex: Option Result { + let printer = a.str("printer")?; + let resolved = if printer == "default" { + spool::printers().into_iter().find(|p| p.default).map(|p| p.name).unwrap_or_else(|| printer.to_string()) + } else { + printer.to_string() + }; + let (papers, default_paper) = spool::printer_paper_info(&resolved); + let list: Vec = papers.into_iter().map(|(name, (width, height))| json!({ "name": name, "width": width, "height": height })).collect(); + let default_val = default_paper.map(|(name, (width, height))| json!({ "name": name, "width": width, "height": height })); + Ok(json!({ "printer": printer, "count": list.len(), "papers": list, "default": default_val })) + } + /// `doc_print`: lay the document out with the Print dialog's options, then save the /// print-ready PDF to `path` or send it to `printer` with the chosen paper. pub(crate) fn doc_print(&mut self, a: &Args) -> Result { @@ -188,7 +239,15 @@ impl Automation { "form-fields-only" => Content::FormFieldsOnly, c => return Err(bad(format!("unknown comments_forms {c:?}"))), }; - let (paper_index, paper) = paper_named(a.opt_str("paper")?)?; + let printer_arg = a.opt_str("printer")?; + let resolved_printer = printer_arg.map(|p| { + if p == "default" { + spool::printers().into_iter().find(|pr| pr.default).map(|pr| pr.name).unwrap_or_else(|| p.to_string()) + } else { + p.to_string() + } + }); + let (paper_index, paper) = resolve_paper(a.opt_str("paper")?, resolved_printer.as_deref())?; // Print as image (Acrobat's Advanced Print Setup): pictures at `dpi`, grey when // `grayscale` is also asked for. let raster = if a.opt_bool("as_image")?.unwrap_or(false) { @@ -212,7 +271,8 @@ impl Automation { out["path"] = json!(path.to_string_lossy()); } (None, Some(printer)) => { - let job = spool_job(a, printer, self.doc(a)?.name.clone(), duplex, paper_index)?; + let actual_printer = resolved_printer.as_deref().unwrap_or(printer); + let job = spool_job(a, actual_printer, self.doc(a)?.name.clone(), duplex, paper_index)?; out["job"] = json!(spool::submit(&bytes, &job).map_err(|e| failed(e.to_string()))?); } _ => return Err(bad("pass either path (save the print-ready PDF) or printer (a name from printers, or \"default\")")), @@ -256,7 +316,7 @@ mod tests { #[test] fn printing_to_a_printer_feeds_the_laid_out_paper() { let job = |args: Value, paper_name: Option<&str>| { - let (index, _) = paper_named(paper_name).unwrap(); + let (index, _) = resolve_paper(paper_name, None).unwrap(); spool_job(&Args(&args), "Office", "a.pdf".into(), None, index).unwrap() }; let media = |m: &str| ("media".to_string(), m.to_string()); @@ -266,14 +326,14 @@ mod tests { assert_eq!((letter.printer.as_deref(), letter.title.as_str()), (Some("Office"), "a.pdf")); // Every paper, by its full name, goes out as its own media name. for (i, (name, size)) in PAPERS.iter().enumerate() { - assert_eq!(paper_named(Some(name)).ok(), Some((i, *size)), "{name}"); + assert_eq!(resolve_paper(Some(name), None).ok(), Some((Some(i), *size)), "{name}"); assert_eq!(job(json!({}), Some(name)).options, print::media_option(i).into_iter().collect::>(), "{name}"); } // Short forms find the same paper. assert_eq!(job(json!({}), Some("letter")).options, [media("Letter")]); assert_eq!(job(json!({}), Some("ARCH D")).options, [media("na_arch-d_24x36in")]); - assert!(matches!(paper_named(Some("Quarto")), Err(ToolError::InvalidArgs(m)) if m.contains("US Letter"))); - assert_eq!(spool_job(&Args(&json!({})), "default", String::new(), None, 0).ok().map(|j| j.printer), Some(None)); - assert!(spool_job(&Args(&json!({ "options": { "InputSlot": 3 } })), "Office", String::new(), None, 0).is_err()); + assert!(matches!(resolve_paper(Some("Quarto"), None), Err(ToolError::InvalidArgs(m)) if m.contains("US Letter"))); + assert_eq!(spool_job(&Args(&json!({})), "default", String::new(), None, Some(0)).ok().map(|j| j.printer), Some(None)); + assert!(spool_job(&Args(&json!({ "options": { "InputSlot": 3 } })), "Office", String::new(), None, Some(0)).is_err()); } } diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 4672e49886..140dbba833 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -591,10 +591,17 @@ pub fn tools() -> Vec { t("printer_options", "List printer options", "A printer driver's own job options (CUPS: from its PPD), such as the paper tray, paper type or finishing: key, label, group, default and choices. Pass chosen values to doc_print as options. Empty on Windows, where the driver's preferences window holds them.") .ro() .with(schema(json!({ "printer": { "type": "string" } }), &["printer"])), + t( + "printer_papers", + "List printer paper sizes", + "The media sizes (names and dimensions in points) and default paper supported by a printer driver. Pass printer: a name from printers, or \"default\".", + ) + .ro() + .with(schema(json!({ "printer": { "type": "string" } }), &["printer"])), t( "doc_print", "Print", - "Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed; default all); subset odd/even (by page number, as Acrobat counts them: pages 2, 7-10 with even prints 2, 8 and 10, not every second page of the range); reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, order, border, auto_rotate; cut-stack order arranges single-sided sheets for cutting into piles and stacking left to right, top to bottom, keeping sheet order within each pile; duplex must be off), booklet (booklet_subset both/front/back, binding left/right; a booklet sets two-sided itself, as Acrobat does: both flips on the short edge, front or back prints one-sided), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid, ARCH A/B/C/D/E1/E, ANSI C/D/E, A0/A1/A2/A3/A4/A5/A6, or US #10 Envelope (short names or full preset labels); use layout actual for 100% in the print-ready PDF; printer drivers may still substitute paper or scale. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale, and options (driver options from printer_options, CUPS).", + "Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed; default all); subset odd/even (by page number, as Acrobat counts them: pages 2, 7-10 with even prints 2, 8 and 10, not every second page of the range); reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, order, border, auto_rotate; cut-stack order arranges single-sided sheets for cutting into piles and stacking left to right, top to bottom, keeping sheet order within each pile; duplex must be off), booklet (booklet_subset both/front/back, binding left/right; a booklet sets two-sided itself, as Acrobat does: both flips on the short edge, front or back prints one-sided), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid, ARCH A/B/C/D/E1/E, ANSI C/D/E, A0/A1/A2/A3/A4/A5/A6, US #10 Envelope, a driver paper name from printer_papers, or dimensions \"x\"; when printer is given and paper is omitted, uses the printer's default paper if available; use layout actual for 100% in the print-ready PDF; printer drivers may still substitute paper or scale. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale, and options (driver options from printer_options, CUPS).", ) .with(schema( json!({ diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 13f9af4371..cba9e1387a 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -4916,6 +4916,25 @@ fn printing_through_tools() { assert!(matches!(a.call("doc_print", &json!({ "doc": doc, "pages": "99", "path": "x.pdf" })), Err(ToolError::InvalidArgs(_)))); } +#[test] +fn printer_papers_through_tools() { + let dir = workdir("printer-papers"); + let mut a = auto(&dir); + let none = ok(&mut a, "printer_papers", json!({ "printer": "No_Such_Queue_PdfCraft" })); + assert_eq!((none["count"].as_u64(), none["papers"].as_array().map(Vec::len)), (Some(0), Some(0))); + assert!(none["default"].is_null()); + assert!(matches!(a.call("printer_papers", &json!({})), Err(ToolError::InvalidArgs(_)))); + + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let r = ok(&mut a, "doc_print", json!({ "doc": doc, "pages": "1", "paper": "300x500", "path": "custom-paper.pdf" })); + assert_eq!(r["sheets"].as_u64(), Some(1)); + let opened = ok(&mut a, "doc_open", json!({ "path": "custom-paper.pdf" }))["doc"].as_u64().unwrap(); + let info = ok(&mut a, "doc_info", json!({ "doc": opened })); + let page = &info["pages"][0]; + assert!((page["width"].as_f64().unwrap() - 300.0).abs() <= 1.0); + assert!((page["height"].as_f64().unwrap() - 500.0).abs() <= 1.0); +} + #[test] fn adding_content_through_tools() { let dir = workdir("content"); diff --git a/crates/print/Cargo.toml b/crates/print/Cargo.toml index 69db783571..0043ee6113 100644 --- a/crates/print/Cargo.toml +++ b/crates/print/Cargo.toml @@ -16,7 +16,7 @@ pdfcraft-model = { workspace = true } thiserror = { workspace = true } [target.'cfg(windows)'.dependencies] -winprint = { version = "0.2.1", git = "https://github.com/storytold/winprint.rs", rev = "5f80b9baf6b58937c401656634e42d33f00ddf49" } +winprint = { version = "0.2.1", git = "https://github.com/storytold/winprint.rs", rev = "5f80b9baf6b58937c401656634e42d33f00ddf49", default-features = false } [dev-dependencies] pdfcraft-render = { workspace = true } diff --git a/crates/print/src/spool.rs b/crates/print/src/spool.rs index 3689a317a9..a8b7ffcd32 100644 --- a/crates/print/src/spool.rs +++ b/crates/print/src/spool.rs @@ -279,8 +279,11 @@ pub fn printer_paper_info(printer: &str) -> (Vec, Option) { (papers, default_paper) } -/// Shows the printer driver's Print Settings / DocumentProperties dialog (Windows). -/// If the user clicks OK, returns the updated paper size. +/// Shows the printer driver's Page Setup / DocumentProperties dialog (Windows). +/// +/// Returns the paper size and orientation selected by the user. Other per-job driver +/// 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> { #[cfg(windows)] { diff --git a/crates/ui-egui/src/workflows/print_ui.rs b/crates/ui-egui/src/workflows/print_ui.rs index c42b889b11..ec6cf891c1 100644 --- a/crates/ui-egui/src/workflows/print_ui.rs +++ b/crates/ui-egui/src/workflows/print_ui.rs @@ -515,6 +515,9 @@ impl PdfCraftApp { /// a preset path returns `true` once the save picker is showing; the file is written on a /// later frame, when the user has chosen where. pub fn print_now(&mut self) -> bool { + if still_looking(&self.print_draft) || self.print_draft.papers_loading() { + return false; + } // What's typed in a form field is part of what's printed (#166). if !self.commit_form_typing() { return false; diff --git a/crates/ui-egui/src/workflows/print_ui/body.rs b/crates/ui-egui/src/workflows/print_ui/body.rs index f53e06ae5b..1d28d254a9 100644 --- a/crates/ui-egui/src/workflows/print_ui/body.rs +++ b/crates/ui-egui/src/workflows/print_ui/body.rs @@ -210,8 +210,12 @@ pub(crate) fn body( let paper_name = d.paper_name().to_string(); let before = d.paper; let mut paper_clicked = false; + let detecting = d.papers_loading(); ui.horizontal(|ui| { craft_ui::ComboBox::from_id_salt("paper").selected_text(tl!(&paper_name)).width(180.0).show_ui(ui, |ui| { + if detecting { + ui.add_enabled(false, egui::Label::new(tl!("Detecting paper…"))); + } for (i, (name, _)) in d.papers.iter().enumerate() { if ui.selectable_value(&mut d.paper, i, tl!(name)).clicked() { paper_clicked = true; @@ -220,8 +224,11 @@ pub(crate) fn body( }) .response .labelled_by(paper_label.id); + if detecting { + ui.label(egui::RichText::new(tl!("Detecting…")).small().color(t.text_muted)); + } let page_setup_tip = if spool::HAS_PRINTER_PREFERENCES { - tl!("Opens the printer driver's page setup: paper size, orientation and source.") + tl!("Selects paper size and orientation from the printer driver. For paper tray, print quality or other driver defaults, use Properties…") } else { tl!("Page setup dialog is not available on this system.") }; @@ -574,8 +581,13 @@ pub(crate) fn body( let (mut go, mut cancel) = (false, false); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let looking = still_looking(d); + let waiting_paper = d.papers_loading(); let label = if d.printer.is_some() || looking { tl!("Print") } else { tl!("Save as PDF") }; - if ui.add_enabled_ui(settings.is_ok() && !sheets.is_empty() && !looking, |ui| widgets::pill_button(ui, label, true)).inner.clicked() { + if ui + .add_enabled_ui(settings.is_ok() && !sheets.is_empty() && !looking && !waiting_paper, |ui| widgets::pill_button(ui, label, true)) + .inner + .clicked() + { go = true; } if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { diff --git a/crates/ui-egui/src/workflows/print_ui/draft.rs b/crates/ui-egui/src/workflows/print_ui/draft.rs index c40f482add..560e8ae0ff 100644 --- a/crates/ui-egui/src/workflows/print_ui/draft.rs +++ b/crates/ui-egui/src/workflows/print_ui/draft.rs @@ -172,6 +172,15 @@ impl PrintDraft { }) } + /// Whether automatic paper detection for the selected printer is in progress and unresolved. + /// + /// When true, the user has not explicitly chosen or overridden the paper, and the selected + /// printer's media sizes and default paper are still being read in the background off the GUI thread. + pub fn papers_loading(&self) -> bool { + let Some(p) = self.printer.as_deref() else { return false }; + !self.paper_overridden && (self.papers_loading.as_deref() == Some(p) || self.read_papers_for.as_deref() == Some(p)) + } + /// Reset an automatically selected custom paper to a standard preset on printer change. /// /// If the currently selected paper does not match any standard preset and `paper_overridden` is false, diff --git a/crates/ui-egui/tests/it/print.rs b/crates/ui-egui/tests/it/print.rs index 21f66ef05a..bd87a384da 100644 --- a/crates/ui-egui/tests/it/print.rs +++ b/crates/ui-egui/tests/it/print.rs @@ -1078,6 +1078,95 @@ fn printer_list_refresh_resets_unoverridden_custom_paper_when_default_printer_ch assert_eq!(h.state().print_draft.paper_name(), "US Letter"); } +#[test] +fn print_button_waits_for_automatic_paper_detection_before_submission() { + use pdfcraft_engine::print::spool; + 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 release_rx = Arc::new(Mutex::new(release_rx)); + let started_tx = Arc::new(Mutex::new(started_tx)); + + h.state_mut().printer_source = + Some(Arc::new(|| vec![spool::Printer { name: "Label 4x6 Printer".into(), default: true, two_sided: None, description: String::new() }])); + let rx_clone = Arc::clone(&release_rx); + let tx_clone = Arc::clone(&started_tx); + h.state_mut().printer_papers_source = Some(Arc::new(move |_| { + let _ = tx_clone.lock().unwrap().send(()); + let _ = rx_clone.lock().unwrap().recv(); + vec![("Shipping Label 4x6".into(), (288.0, 432.0))] + })); + h.state_mut().default_paper_source = Some(Arc::new(|_| Some(("Shipping Label 4x6".into(), (288.0, 432.0))))); + + assert!(h.state_mut().execute("print.dialog")); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while h.state().print_draft.printers_loading && std::time::Instant::now() < deadline { + h.run_steps(1); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + // Wait until background paper worker has reached the channel gate + let _ = started_rx.recv_timeout(std::time::Duration::from_secs(5)); + + // While background read is pending and paper is not manually overridden: + assert!(h.state().print_draft.papers_loading(), "papers_loading must be true while automatic detection is pending"); + assert!(!h.state_mut().print_now(), "print_now must be refused while paper detection is pending"); + + // Overriding paper manually resolves the pending state and enables submission immediately + h.state_mut().print_draft.paper_overridden = true; + assert!(!h.state().print_draft.papers_loading(), "papers_loading becomes false once paper is overridden"); + assert!(h.state_mut().print_now(), "print_now succeeds when user has chosen a paper"); + + // Unblock the worker so the thread finishes cleanly + let _ = release_tx.send(()); +} + +#[test] +fn gated_paper_detection_enables_print_after_worker_finishes() { + use pdfcraft_engine::print::spool; + 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 release_rx = Arc::new(Mutex::new(release_rx)); + let started_tx = Arc::new(Mutex::new(started_tx)); + + h.state_mut().printer_source = + Some(Arc::new(|| vec![spool::Printer { name: "Label 4x6 Printer".into(), default: true, two_sided: None, description: String::new() }])); + let rx_clone = Arc::clone(&release_rx); + let tx_clone = Arc::clone(&started_tx); + h.state_mut().printer_papers_source = Some(Arc::new(move |_| { + let _ = tx_clone.lock().unwrap().send(()); + let _ = rx_clone.lock().unwrap().recv(); + vec![("Shipping Label 4x6".into(), (288.0, 432.0))] + })); + h.state_mut().default_paper_source = Some(Arc::new(|_| Some(("Shipping Label 4x6".into(), (288.0, 432.0))))); + + assert!(h.state_mut().execute("print.dialog")); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while h.state().print_draft.printers_loading && std::time::Instant::now() < deadline { + h.run_steps(1); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + let _ = started_rx.recv_timeout(std::time::Duration::from_secs(5)); + + assert!(h.state().print_draft.papers_loading()); + assert!(!h.state_mut().print_now()); + + // Release the worker + let _ = release_tx.send(()); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while h.state().print_draft.papers_loading() && std::time::Instant::now() < deadline { + h.run_steps(1); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(!h.state().print_draft.papers_loading()); + assert_eq!(h.state().print_draft.paper_size(), (288.0, 432.0)); + assert!(h.state_mut().print_now()); +} + #[test] fn restored_paper_keeps_preview_and_saved_sheet_dimensions() { use pdfcraft_engine::print::{self, PAPERS}; diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 8b7ac909a0..9eb44dd9e2 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -8280,11 +8280,15 @@ title = "Page setup" area = "L" tier = "P1" milestone = "M10" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["print.dialog"] +tools = ["printer_papers"] +evidence = [ + "crates/ui-egui/tests/it/print.rs::printer_papers_and_properties_selection", + "crates/automation/tests/it/automation.rs::printer_papers_through_tools", + "crates/print/src/tests.rs::printer_papers_and_default_paper_api", +] +notes = "The Print dialog's Page Setup button opens the driver's page setup to choose paper size and orientation. printer_papers enumerates driver-reported papers and default paper headlessly." [[feature]] id = "print.print-ready-pdf" From 43f94aaaa5d95418b36a9cb7c1b56bf484c31cb5 Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Mon, 9 Nov 2026 16:13:57 +0530 Subject: [PATCH 12/19] fix(print): carry media option for custom and detected driver papers in spool jobs --- crates/automation/src/printing.rs | 60 +++++++++---------- crates/print/src/lib.rs | 2 +- crates/print/src/settings.rs | 46 ++++++++++++++ crates/print/src/tests.rs | 22 +++++++ .../ui-egui/src/workflows/print_ui/draft.rs | 2 +- 5 files changed, 100 insertions(+), 32 deletions(-) diff --git a/crates/automation/src/printing.rs b/crates/automation/src/printing.rs index 919e8d958a..2a562a78d3 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -37,9 +37,9 @@ fn bad(m: impl Into) -> ToolError { ToolError::InvalidArgs(m.into()) } -/// Parse explicit custom dimensions like "300x500", "300 x 500", "300x500 pt". +/// Parse explicit custom dimensions like "300x500", "300 x 500", "300x500 pt", "Custom.300x500". fn parse_custom_dimensions(s: &str) -> Option<(f64, f64)> { - let s = s.trim().trim_end_matches("pt").trim_end_matches("points").trim(); + let s = s.trim().trim_start_matches("Custom.").trim_start_matches("custom.").trim_end_matches("pt").trim_end_matches("points").trim(); let (w_str, h_str) = s.split_once(['x', 'X', '*'])?; let w: f64 = w_str.trim().parse().ok()?; let h: f64 = h_str.trim().parse().ok()?; @@ -47,8 +47,8 @@ fn parse_custom_dimensions(s: &str) -> Option<(f64, f64)> { } /// `doc_print`'s `paper`, by its name, short form, driver-detected paper name, or custom dimensions. -/// Returns `(matching_papers_index, (width, height))`. -fn resolve_paper(name: Option<&str>, printer: Option<&str>) -> Result<(Option, (f64, f64))> { +/// Returns `((width, height), media_name)`. +fn resolve_paper(name: Option<&str>, printer: Option<&str>) -> Result<((f64, f64), String)> { if let Some(p) = name { // 1. Match standard PAPERS presets. for (index, (n, size)) in PAPERS.iter().enumerate() { @@ -56,26 +56,20 @@ fn resolve_paper(name: Option<&str>, printer: Option<&str>) -> Result<(Optionx"). + // 3. Match custom dimensions ("x"). if let Some(size) = parse_custom_dimensions(p) { - return Ok((print::matching_paper(size), size)); + return Ok((size, print::paper_media(None, size))); } return Err(bad(format!( "unknown paper {p:?}; choose one of {}, a paper from printer_papers, or dimensions \"x\"", @@ -85,19 +79,19 @@ fn resolve_paper(name: Option<&str>, printer: Option<&str>) -> Result<(Option, paper: Option) -> Result { +/// (as `media` after the driver's `options`) rather than the queue's default paper, which a job +/// naming none got (A4 under Letter sheets on the queue in #3752). +fn spool_job(a: &Args, printer: &str, title: String, duplex: Option, media: Option<&str>) -> Result { let options = match a.get("options") { None => Vec::new(), Some(Value::Object(m)) => m @@ -117,7 +111,7 @@ fn spool_job(a: &Args, printer: &str, title: String, duplex: Option { let actual_printer = resolved_printer.as_deref().unwrap_or(printer); - let job = spool_job(a, actual_printer, self.doc(a)?.name.clone(), duplex, paper_index)?; + let job = spool_job(a, actual_printer, self.doc(a)?.name.clone(), duplex, Some(&media))?; out["job"] = json!(spool::submit(&bytes, &job).map_err(|e| failed(e.to_string()))?); } _ => return Err(bad("pass either path (save the print-ready PDF) or printer (a name from printers, or \"default\")")), @@ -316,8 +310,8 @@ mod tests { #[test] fn printing_to_a_printer_feeds_the_laid_out_paper() { let job = |args: Value, paper_name: Option<&str>| { - let (index, _) = resolve_paper(paper_name, None).unwrap(); - spool_job(&Args(&args), "Office", "a.pdf".into(), None, index).unwrap() + let (_, media) = resolve_paper(paper_name, None).unwrap(); + spool_job(&Args(&args), "Office", "a.pdf".into(), None, Some(&media)).unwrap() }; let media = |m: &str| ("media".to_string(), m.to_string()); // US Letter when no paper is asked for, after the driver's own options. @@ -326,14 +320,20 @@ mod tests { assert_eq!((letter.printer.as_deref(), letter.title.as_str()), (Some("Office"), "a.pdf")); // Every paper, by its full name, goes out as its own media name. for (i, (name, size)) in PAPERS.iter().enumerate() { - assert_eq!(resolve_paper(Some(name), None).ok(), Some((Some(i), *size)), "{name}"); + assert_eq!(resolve_paper(Some(name), None).ok(), Some((*size, print::cups_media(i).unwrap().to_string())), "{name}"); assert_eq!(job(json!({}), Some(name)).options, print::media_option(i).into_iter().collect::>(), "{name}"); } // Short forms find the same paper. assert_eq!(job(json!({}), Some("letter")).options, [media("Letter")]); assert_eq!(job(json!({}), Some("ARCH D")).options, [media("na_arch-d_24x36in")]); + + // Non-preset dimensions and driver papers carry their media selection. + assert_eq!(job(json!({}), Some("300x500")).options, [media("Custom.300x500")]); + assert_eq!(job(json!({}), Some("300 x 500 pt")).options, [media("Custom.300x500")]); + assert_eq!(job(json!({}), Some("Custom.300x500")).options, [media("Custom.300x500")]); + assert!(matches!(resolve_paper(Some("Quarto"), None), Err(ToolError::InvalidArgs(m)) if m.contains("US Letter"))); - assert_eq!(spool_job(&Args(&json!({})), "default", String::new(), None, Some(0)).ok().map(|j| j.printer), Some(None)); - assert!(spool_job(&Args(&json!({ "options": { "InputSlot": 3 } })), "Office", String::new(), None, Some(0)).is_err()); + assert_eq!(spool_job(&Args(&json!({})), "default", String::new(), None, Some("Letter")).ok().map(|j| j.printer), Some(None)); + assert!(spool_job(&Args(&json!({ "options": { "InputSlot": 3 } })), "Office", String::new(), None, Some("Letter")).is_err()); } } diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index d4cecebd7a..9ee2820dcc 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -38,7 +38,7 @@ use imposition::{PAGES_LISTED, appearance, appearance_matrix, decoded_contents, pub use imposition::{impose, keep_printed, sheet_pages}; pub use settings::{ Binding, BookletSubset, Content, Crop, LABEL_PAPER, Layout, Orientation, PAPERS, PageOrder, Raster, Settings, SizeMode, booklet_duplex, - cups_media, media_option, + cups_media, custom_media, media_option, paper_media, paper_media_option, }; #[cfg(test)] use sheets::text_direction; diff --git a/crates/print/src/settings.rs b/crates/print/src/settings.rs index 0506eb5b18..1ed2665f74 100644 --- a/crates/print/src/settings.rs +++ b/crates/print/src/settings.rs @@ -1,4 +1,5 @@ use super::spool; +use crate::matching_paper; #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum Orientation { @@ -248,3 +249,48 @@ pub fn cups_media(index: usize) -> Option<&'static str> { pub fn media_option(index: usize) -> Option<(String, String)> { cups_media(index).map(|m| ("media".to_string(), m.to_string())) } + +/// Format custom dimensions in points as a CUPS custom media string (e.g. `Custom.300x500`). +pub fn custom_media(size: (f64, f64)) -> String { + let format_dim = |d: f64| { + if (d.round() - d).abs() < 1e-4 { + format!("{:.0}", d.round()) + } else { + let s = format!("{d:.2}"); + s.trim_end_matches('0').trim_end_matches('.').to_string() + } + }; + format!("Custom.{}x{}", format_dim(size.0), format_dim(size.1)) +} + +/// The CUPS media name for a paper by its name and dimensions. +/// +/// Matches standard [`PAPERS`] presets by size first, then a clean driver-reported keyword, +/// falling back to custom media dimensions (`Custom.x`). +pub fn paper_media(name: Option<&str>, size: (f64, f64)) -> String { + if let Some(index) = matching_paper(size) + && let Some(media) = cups_media(index) + { + return media.to_string(); + } + if let Some(n) = name { + let is_dim = n.split_once(['x', 'X', '*']).is_some_and(|(w, h)| { + w.trim().trim_start_matches("Custom.").trim_start_matches("custom.").parse::().is_ok() + && h.trim().trim_end_matches("pt").trim_end_matches("points").trim().parse::().is_ok() + }); + let is_clean = !n.is_empty() + && n.len() <= 64 + && !n.contains(' ') + && !is_dim + && n.chars().all(|c| c.is_ascii_graphic() && !matches!(c, '/' | ':' | '=' | '"' | '\'' | '\\' | '{')); + if is_clean { + return n.to_string(); + } + } + custom_media(size) +} + +/// The media job option (`("media", )`) for a paper by its optional name and dimensions. +pub fn paper_media_option(name: Option<&str>, size: (f64, f64)) -> (String, String) { + ("media".to_string(), paper_media(name, size)) +} diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index 6e9a2abe5a..8deaaccf89 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -852,6 +852,28 @@ fn every_paper_reaches_the_spooler_by_a_name_of_its_size() { ); } +#[test] +fn paper_media_and_custom_dimensions() { + assert_eq!(custom_media((300.0, 500.0)), "Custom.300x500"); + assert_eq!(custom_media((300.5, 500.25)), "Custom.300.5x500.25"); + + // Standard presets match by size regardless of name: + assert_eq!(paper_media(None, (612.0, 792.0)), "Letter"); + assert_eq!(paper_media(Some("Driver Letter"), (612.0, 792.0)), "Letter"); + assert_eq!(paper_media(Some("Shipping Label"), (288.0, 432.0)), "w288h432"); + + // Clean driver keyword that does not match PAPERS presets: + assert_eq!(paper_media(Some("Roll80mm"), (226.77, 800.0)), "Roll80mm"); + assert_eq!(paper_media(Some("Photo5x7"), (360.0, 504.0)), "Photo5x7"); + + // Driver paper with spaces or raw dimensions falls back to Custom: + assert_eq!(paper_media(Some("Custom 5x7"), (360.0, 504.0)), "Custom.360x504"); + assert_eq!(paper_media(Some("300x500"), (300.0, 500.0)), "Custom.300x500"); + assert_eq!(paper_media(None, (300.0, 500.0)), "Custom.300x500"); + + assert_eq!(paper_media_option(Some("300x500"), (300.0, 500.0)), ("media".to_string(), "Custom.300x500".to_string())); +} + /// #2734: the US #10 envelope (4.125 × 9.5 in) is offered, fed by its PPD name and laid out at /// its own size. #[test] diff --git a/crates/ui-egui/src/workflows/print_ui/draft.rs b/crates/ui-egui/src/workflows/print_ui/draft.rs index 560e8ae0ff..7939c4a047 100644 --- a/crates/ui-egui/src/workflows/print_ui/draft.rs +++ b/crates/ui-egui/src/workflows/print_ui/draft.rs @@ -421,7 +421,7 @@ impl PrintDraft { } .into_iter() // The sheet the PDF was laid out on, not the queue's default paper. - .chain(print::media_option(self.paper)) + .chain(self.papers.get(self.paper).map(|(name, size)| print::paper_media_option(Some(name), *size))) .collect(), } } From d4d8a87c178226a287dca5153ddc17ab0d77bb3c Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Mon, 9 Nov 2026 17:22:43 +0530 Subject: [PATCH 13/19] fix(print): restore papers_loading marker when reusing background worker --- crates/ui-egui/src/workflows/print_ui.rs | 2 +- crates/ui-egui/tests/it/print.rs | 89 ++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/workflows/print_ui.rs b/crates/ui-egui/src/workflows/print_ui.rs index ec6cf891c1..817c8063e1 100644 --- a/crates/ui-egui/src/workflows/print_ui.rs +++ b/crates/ui-egui/src/workflows/print_ui.rs @@ -312,10 +312,10 @@ impl PdfCraftApp { } } } + self.print_draft.papers_loading = Some(printer.clone()); if self.print_tasks.papers.iter().any(|(p, _)| *p == printer) { return; } - self.print_draft.papers_loading = Some(printer.clone()); let papers_source = self.printer_papers_source.clone(); let default_source = self.default_paper_source.clone(); let (tx, rx) = std::sync::mpsc::channel(); diff --git a/crates/ui-egui/tests/it/print.rs b/crates/ui-egui/tests/it/print.rs index bd87a384da..130f5d32e1 100644 --- a/crates/ui-egui/tests/it/print.rs +++ b/crates/ui-egui/tests/it/print.rs @@ -1167,6 +1167,95 @@ fn gated_paper_detection_enables_print_after_worker_finishes() { assert!(h.state_mut().print_now()); } +#[test] +fn gated_paper_detection_blocks_print_on_printer_switch_reuse_until_resolved() { + use pdfcraft_engine::print::spool; + use std::sync::{Arc, Mutex, mpsc}; + + let mut h = harness(); + let (release_a_tx, release_a_rx) = mpsc::channel(); + let (started_a_tx, started_a_rx) = mpsc::channel(); + let release_a_rx = Arc::new(Mutex::new(release_a_rx)); + let started_a_tx = Arc::new(Mutex::new(started_a_tx)); + + let (release_b_tx, release_b_rx) = mpsc::channel(); + let (started_b_tx, started_b_rx) = mpsc::channel(); + let release_b_rx = Arc::new(Mutex::new(release_b_rx)); + let started_b_tx = Arc::new(Mutex::new(started_b_tx)); + + h.state_mut().printer_source = Some(Arc::new(|| { + vec![ + spool::Printer { name: "Printer A".into(), default: true, two_sided: None, description: String::new() }, + spool::Printer { name: "Printer B".into(), default: false, two_sided: None, description: String::new() }, + ] + })); + + let rx_a = Arc::clone(&release_a_rx); + let tx_a = Arc::clone(&started_a_tx); + let rx_b = Arc::clone(&release_b_rx); + let tx_b = Arc::clone(&started_b_tx); + h.state_mut().printer_papers_source = Some(Arc::new(move |p| { + if p == "Printer A" { + let _ = tx_a.lock().unwrap().send(()); + let _ = rx_a.lock().unwrap().recv(); + vec![("A-Paper 4x6".into(), (288.0, 432.0))] + } else { + let _ = tx_b.lock().unwrap().send(()); + let _ = rx_b.lock().unwrap().recv(); + vec![("B-Paper 5x7".into(), (360.0, 504.0))] + } + })); + h.state_mut().default_paper_source = Some(Arc::new(|p| { + if p == "Printer A" { + Some(("A-Paper 4x6".into(), (288.0, 432.0))) + } else { + Some(("B-Paper 5x7".into(), (360.0, 504.0))) + } + })); + + // 1. Open print dialog: Printer A is default and starts reading papers + assert!(h.state_mut().execute("print.dialog")); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while h.state().print_draft.printers_loading && std::time::Instant::now() < deadline { + h.run_steps(1); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + let _ = started_a_rx.recv_timeout(std::time::Duration::from_secs(5)); + assert!(h.state().print_draft.papers_loading()); + assert!(!h.state_mut().print_now()); + + // 2. Switch to Printer B + h.state_mut().print_draft.printer = Some("Printer B".into()); + h.state_mut().print_draft.read_papers_for = Some("Printer B".into()); + h.run_steps(1); + let _ = started_b_rx.recv_timeout(std::time::Duration::from_secs(5)); + assert!(h.state().print_draft.papers_loading()); + assert!(!h.state_mut().print_now()); + + // 3. Switch back to Printer A (A's read is still in flight, reusing worker) + h.state_mut().print_draft.printer = Some("Printer A".into()); + h.state_mut().print_draft.read_papers_for = Some("Printer A".into()); + h.run_steps(1); + + // Guard must still hold for Printer A + assert!(h.state().print_draft.papers_loading(), "papers_loading must be restored for Printer A when reusing its worker"); + assert!(!h.state_mut().print_now(), "print_now must be refused while Printer A's read is still pending after switch back"); + + // 4. Release Printer A's read + let _ = release_a_tx.send(()); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while h.state().print_draft.papers_loading() && std::time::Instant::now() < deadline { + h.run_steps(1); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(!h.state().print_draft.papers_loading()); + assert_eq!(h.state().print_draft.paper_size(), (288.0, 432.0)); + assert!(h.state_mut().print_now()); + + // Clean up Printer B worker + let _ = release_b_tx.send(()); +} + #[test] fn restored_paper_keeps_preview_and_saved_sheet_dimensions() { use pdfcraft_engine::print::{self, PAPERS}; From a1cc08ab037b31d189aa51cd57da88579c44a5fd Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Mon, 9 Nov 2026 19:09:56 +0530 Subject: [PATCH 14/19] chore: format print tests and lock winprint dependency --- Cargo.lock | 25 +++++++++++++++++++++++-- crates/print/src/tests.rs | 1 - crates/ui-egui/tests/it/print.rs | 13 ++++++------- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6f7b741a3c..e793c409ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1250,7 +1250,7 @@ dependencies = [ "miniz_oxide 0.9.1", "roxmltree 0.21.2", "tempfile", - "winprint", + "winprint 0.2.1 (git+https://github.com/storytold/winprint.rs?rev=51934db382f225454cf152a8657eb03789be63c4)", "winsafe", ] @@ -4661,7 +4661,7 @@ dependencies = [ "pdfcraft-model", "pdfcraft-render", "thiserror 2.0.21", - "winprint", + "winprint 0.2.1 (git+https://github.com/storytold/winprint.rs?rev=5f80b9baf6b58937c401656634e42d33f00ddf49)", ] [[package]] @@ -8327,6 +8327,27 @@ dependencies = [ "xml", ] +[[package]] +name = "winprint" +version = "0.2.1" +source = "git+https://github.com/storytold/winprint.rs?rev=5f80b9baf6b58937c401656634e42d33f00ddf49#5f80b9baf6b58937c401656634e42d33f00ddf49" +dependencies = [ + "flate2", + "fmt-derive", + "reqwest", + "scopeguard", + "sigstore-trust-root", + "sigstore-types", + "sigstore-verify", + "strum", + "tar", + "thiserror 2.0.21", + "windows", + "windows-core", + "windows-implement", + "xml", +] + [[package]] name = "winreg" version = "0.55.0" diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index 1ecccccb3d..ff32f72b1f 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -1403,4 +1403,3 @@ fn texts_read_up_answers_many_pages_from_one_walk() { assert!(up.iter().all(|u| !u), "upright text, and no page 3000"); assert_eq!(up[1], text_reads_up(&doc, 1)); } -} diff --git a/crates/ui-egui/tests/it/print.rs b/crates/ui-egui/tests/it/print.rs index 384080b9e8..040a858b5b 100644 --- a/crates/ui-egui/tests/it/print.rs +++ b/crates/ui-egui/tests/it/print.rs @@ -1237,13 +1237,12 @@ fn gated_paper_detection_blocks_print_on_printer_switch_reuse_until_resolved() { vec![("B-Paper 5x7".into(), (360.0, 504.0))] } })); - h.state_mut().default_paper_source = Some(Arc::new(|p| { - if p == "Printer A" { - Some(("A-Paper 4x6".into(), (288.0, 432.0))) - } else { - Some(("B-Paper 5x7".into(), (360.0, 504.0))) - } - })); + h.state_mut().default_paper_source = + Some(Arc::new( + |p| { + if p == "Printer A" { Some(("A-Paper 4x6".into(), (288.0, 432.0))) } else { Some(("B-Paper 5x7".into(), (360.0, 504.0))) } + }, + )); // 1. Open print dialog: Printer A is default and starts reading papers assert!(h.state_mut().execute("print.dialog")); From acae52111531ceb7093502c5212dc92c277bbb5d Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Mon, 9 Nov 2026 19:33:31 +0530 Subject: [PATCH 15/19] fix(print): robust custom dimension parsing, spool media option fallback, driver paper bounds and name resolution --- crates/automation/src/printing.rs | 15 +++++++++++---- crates/print/src/settings.rs | 7 +++++-- crates/print/src/spool.rs | 3 ++- crates/ui-egui/src/workflows/print_ui/draft.rs | 8 ++++++-- 4 files changed, 24 insertions(+), 9 deletions(-) diff --git a/crates/automation/src/printing.rs b/crates/automation/src/printing.rs index ef36b5d3a0..6e3d528113 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -37,12 +37,16 @@ fn bad(m: impl Into) -> ToolError { ToolError::InvalidArgs(m.into()) } -/// Parse explicit custom dimensions like "300x500", "300 x 500", "300x500 pt", "Custom.300x500". +/// Parse explicit custom dimensions like "300x500", "300 x 500", "300x500 pt", "300pt x 500pt", "Custom.300x500". fn parse_custom_dimensions(s: &str) -> Option<(f64, f64)> { - let s = s.trim().trim_start_matches("Custom.").trim_start_matches("custom.").trim_end_matches("pt").trim_end_matches("points").trim(); + let s = s.trim().trim_start_matches("Custom.").trim_start_matches("custom.").trim(); let (w_str, h_str) = s.split_once(['x', 'X', '*'])?; - let w: f64 = w_str.trim().parse().ok()?; - let h: f64 = h_str.trim().parse().ok()?; + let parse_dim = |d: &str| { + let d = d.trim().trim_end_matches("points").trim_end_matches("pt").trim(); + d.parse::().ok() + }; + let w = parse_dim(w_str)?; + let h = parse_dim(h_str)?; if w.is_finite() && h.is_finite() && w >= 72.0 && h >= 72.0 && w <= 14400.0 && h <= 14400.0 { Some((w, h)) } else { None } } @@ -346,7 +350,10 @@ mod tests { // Non-preset dimensions and driver papers carry their media selection. assert_eq!(job(json!({}), Some("300x500")).options, [media("Custom.300x500")]); assert_eq!(job(json!({}), Some("300 x 500 pt")).options, [media("Custom.300x500")]); + assert_eq!(job(json!({}), Some("300pt x 500pt")).options, [media("Custom.300x500")]); + assert_eq!(job(json!({}), Some("300 pt x 500 pt")).options, [media("Custom.300x500")]); assert_eq!(job(json!({}), Some("Custom.300x500")).options, [media("Custom.300x500")]); + 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"))); assert_eq!(spool_job(&Args(&json!({})), "default", String::new(), None, Some("Letter")).ok().map(|j| j.printer), Some(None)); diff --git a/crates/print/src/settings.rs b/crates/print/src/settings.rs index ccfd6417c1..ea2c629c39 100644 --- a/crates/print/src/settings.rs +++ b/crates/print/src/settings.rs @@ -294,8 +294,11 @@ pub fn paper_media(name: Option<&str>, size: (f64, f64)) -> String { } if let Some(n) = name { let is_dim = n.split_once(['x', 'X', '*']).is_some_and(|(w, h)| { - w.trim().trim_start_matches("Custom.").trim_start_matches("custom.").parse::().is_ok() - && h.trim().trim_end_matches("pt").trim_end_matches("points").trim().parse::().is_ok() + let parse_dim = |d: &str| { + let d = d.trim().trim_start_matches("Custom.").trim_start_matches("custom.").trim_end_matches("points").trim_end_matches("pt").trim(); + d.parse::().is_ok() + }; + parse_dim(w) && parse_dim(h) }); let is_clean = !n.is_empty() && n.len() <= 64 diff --git a/crates/print/src/spool.rs b/crates/print/src/spool.rs index a8b7ffcd32..b013d51866 100644 --- a/crates/print/src/spool.rs +++ b/crates/print/src/spool.rs @@ -223,7 +223,7 @@ pub fn printer_papers(printer: &str) -> Vec { let size = media.size(); let w_pts = (size.width_in_micron() as f64) * 72.0 / 25_400.0; let h_pts = (size.height_in_micron() as f64) * 72.0 / 25_400.0; - if w_pts > 0.0 && h_pts > 0.0 { + if w_pts.is_finite() && h_pts.is_finite() && w_pts >= 72.0 && h_pts >= 72.0 && w_pts <= 14400.0 && h_pts <= 14400.0 { let name = media.display_name().map(str::to_string).unwrap_or_else(|| { if let Some(predefined) = media.as_predefined_name() { format!("{predefined:?}") } else { format!("{w_pts:.1} x {h_pts:.1} pt") } }); @@ -252,6 +252,7 @@ fn resolve_paper_name(papers: &[Paper], paper_pts: (f64, f64)) -> Paper { .iter() .find(|(_, (w, h))| ((*w - w_pts).abs() <= 1.0 && (*h - h_pts).abs() <= 1.0) || ((*w - h_pts).abs() <= 1.0 && (*h - w_pts).abs() <= 1.0)) .map(|(n, _)| n.clone()) + .or_else(|| crate::matching_paper(paper_pts).and_then(|idx| crate::PAPERS.get(idx).map(|(n, _)| n.to_string()))) .unwrap_or_default(); (name, (w_pts, h_pts)) } diff --git a/crates/ui-egui/src/workflows/print_ui/draft.rs b/crates/ui-egui/src/workflows/print_ui/draft.rs index 98452ff02c..5692118134 100644 --- a/crates/ui-egui/src/workflows/print_ui/draft.rs +++ b/crates/ui-egui/src/workflows/print_ui/draft.rs @@ -297,7 +297,7 @@ impl PrintDraft { if !(size.0 >= 72.0 && size.1 >= 72.0 && size.0.is_finite() && size.1.is_finite()) { return None; } - if let Some(pos) = self.matching_paper(size) { + if let Some(pos) = print::matching_paper(size) { Some(pos) } else if !name.is_empty() { let norm = (size.0.min(size.1), size.0.max(size.1)); @@ -308,10 +308,14 @@ impl PrintDraft { } }) { Some(pos) + } else if let Some(pos) = self.matching_paper(size) { + Some(pos) } else { self.papers.push((name, size)); Some(self.papers.len() - 1) } + } else if let Some(pos) = self.matching_paper(size) { + Some(pos) } else { let custom_name = format!("{:.1} x {:.1} pt", size.0, size.1); self.papers.push((custom_name, size)); @@ -435,7 +439,7 @@ impl PrintDraft { } .into_iter() // The sheet the PDF was laid out on, not the queue's default paper. - .chain(self.papers.get(self.paper).map(|(name, size)| print::paper_media_option(Some(name), *size))) + .chain(Some(print::paper_media_option(Some(self.paper_name()), self.paper_size()))) .collect(), } } From 6df356b63dd2411199b7506252283b04f8ea99cf Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Tue, 10 Nov 2026 11:06:56 +0530 Subject: [PATCH 16/19] fix(open): submit password retries with priority when worker queue is full --- crates/ui-egui/src/app/files.rs | 19 +++++- crates/ui-egui/src/platform/open_worker.rs | 66 +++++++++++++++---- .../ui-egui/src/platform/open_worker/tests.rs | 49 ++++++++++++++ 3 files changed, 117 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index a86c72ef7d..ec264b07e3 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -105,6 +105,16 @@ impl PdfCraftApp { #[cfg(not(target_arch = "wasm32"))] pub(crate) fn schedule_open(&mut self, request: OpenRequest) -> Result { + self.schedule_open_with_priority(request, false) + } + + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn schedule_open_priority(&mut self, request: OpenRequest) -> Result { + self.schedule_open_with_priority(request, true) + } + + #[cfg(not(target_arch = "wasm32"))] + fn schedule_open_with_priority(&mut self, request: OpenRequest, priority: bool) -> Result { if self.open_worker.is_none() { self.open_worker = Some( open_worker::OpenWorker::new( @@ -119,8 +129,11 @@ impl PdfCraftApp { if let Some(ctx) = &self.ctx { worker.set_context(ctx); } - let receiver = - worker.submit(token, self.session.open_context(), request.name.clone(), request.path.clone(), request.password, request.input)?; + let receiver = if priority { + worker.submit_priority(token, self.session.open_context(), request.name.clone(), request.path.clone(), request.password, request.input)? + } else { + worker.submit(token, self.session.open_context(), request.name.clone(), request.path.clone(), request.password, request.input)? + }; self.next_open_token = token; self.pending_opens.push(PendingOpen { token, @@ -497,7 +510,7 @@ impl PdfCraftApp { }; #[cfg(not(target_arch = "wasm32"))] if self.ctx.is_some() && !self.run_inline { - let result = self.schedule_open(OpenRequest { + let result = self.schedule_open_priority(OpenRequest { name: p.name.clone(), path: p.path.clone(), stamp: p.stamp, diff --git a/crates/ui-egui/src/platform/open_worker.rs b/crates/ui-egui/src/platform/open_worker.rs index 274071ea78..24211ffc3a 100644 --- a/crates/ui-egui/src/platform/open_worker.rs +++ b/crates/ui-egui/src/platform/open_worker.rs @@ -83,29 +83,67 @@ impl OpenWorker { password: Option, input: Input, ) -> Result, String> { + let (job, receiver) = self.build_job(token, context, name, path, password, input); + self.enqueue(job, false)?; + Ok(receiver) + } + + pub fn submit_priority( + &self, + token: u64, + context: OpenContext, + name: String, + path: Option, + password: Option, + input: Input, + ) -> Result, String> { + let (job, receiver) = self.build_job(token, context, name, path, password, input); + self.enqueue(job, true)?; + Ok(receiver) + } + + fn build_job( + &self, + token: u64, + context: OpenContext, + name: String, + path: Option, + password: Option, + input: Input, + ) -> (Job, Receiver) { let (reply, receiver) = mpsc::channel(); - let job = Job { - token, - context, - name, - path, - password, - input, - reply, - #[cfg(test)] - gate: self.gate.clone(), - }; + ( + Job { + token, + context, + name, + path, + password, + input, + reply, + #[cfg(test)] + gate: self.gate.clone(), + }, + receiver, + ) + } + + fn enqueue(&self, job: Job, priority: bool) -> Result<(), String> { let (lock, ready) = &*self.queue; let mut queue = lock.lock().unwrap_or_else(PoisonError::into_inner); if queue.shutdown { return Err("the document-open worker has stopped".into()); } - if queue.jobs.len() >= MAX_QUEUED_OPENS { + if !priority && queue.jobs.len() >= MAX_QUEUED_OPENS { return Err("too many documents are waiting to open".into()); } - queue.jobs.push_back(job); + if priority { + queue.jobs.push_front(job); + } else { + queue.jobs.push_back(job); + } ready.notify_one(); - Ok(receiver) + Ok(()) } } diff --git a/crates/ui-egui/src/platform/open_worker/tests.rs b/crates/ui-egui/src/platform/open_worker/tests.rs index efe911bff6..a39bb9fd4e 100644 --- a/crates/ui-egui/src/platform/open_worker/tests.rs +++ b/crates/ui-egui/src/platform/open_worker/tests.rs @@ -921,3 +921,52 @@ fn a_repeated_waiting_open_keeps_the_tool_it_brings() { assert_eq!(app.open_path(&path), crate::OpenOutcome::Pending); assert_eq!(app.deferred_opens.front().and_then(|d| d.then), Some("export.text")); } + +/// A password retry submits with priority and succeeds even when the open worker queue is full +/// of ordinary background documents, placing the retry at the head of the worker queue. +#[test] +#[allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] +fn password_retry_submits_with_priority_when_worker_queue_is_full() { + let (gate, started, release, finished) = open_worker::prepare_gate(); + let worker = open_worker::OpenWorker::new(Some(gate)).expect("worker"); + let pdf = Arc::new(fixture(1)); + let open_context = pdfcraft_engine::Session::new().open_context(); + + // Fill the worker queue to its limit while the gate holds the worker on the first job. + let _first = + worker.submit(1, open_context.clone(), "first.pdf".into(), None, None, open_worker::Input::Bytes(pdf.clone())).expect("submit first"); + started.recv_timeout(Duration::from_secs(5)).expect("first job reached gate"); + + for token in 2..=(open_worker::MAX_QUEUED_OPENS as u64 + 1) { + worker + .submit(token, open_context.clone(), format!("doc-{token}.pdf"), None, None, open_worker::Input::Bytes(pdf.clone())) + .expect("queue up to limit"); + } + + // At capacity, an ordinary submit fails with the capacity error. + let overflow = worker.submit(999, open_context.clone(), "overflow.pdf".into(), None, None, open_worker::Input::Bytes(pdf.clone())); + assert_eq!(overflow.err(), Some("too many documents are waiting to open".into())); + + // A priority submit (such as a password prompt retry) succeeds and enters the queue. + let priority_token = 1000; + let priority_recv = worker.submit_priority( + priority_token, + open_context.clone(), + "password-retry.pdf".into(), + None, + Some("secret".into()), + open_worker::Input::Bytes(pdf.clone()), + ); + assert!(priority_recv.is_ok(), "priority submit must bypass the queued opens limit"); + + // Release the first job; the priority job must be processed next because it was pushed to the front. + release.send(()).expect("release first"); + finished.recv_timeout(Duration::from_secs(5)).expect("first finished"); + + started.recv_timeout(Duration::from_secs(5)).expect("priority job reached gate next"); + release.send(()).expect("release priority"); + finished.recv_timeout(Duration::from_secs(5)).expect("priority finished"); + + let priority_result = priority_recv.unwrap().recv_timeout(Duration::from_secs(5)).expect("priority result"); + assert_eq!(priority_result.token, priority_token); +} From 257c026c8ae3e1a248b2a601a470d4eb190b2cdb Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:42:49 +0100 Subject: [PATCH 17/19] fix(print): guard paper discovery and retain explicit custom stock --- crates/automation/src/printing.rs | 20 ++++-- crates/print/src/spool.rs | 12 ++++ crates/print/src/tests.rs | 30 +++++++++ crates/ui-egui/src/workflows/print_paper.rs | 2 + .../ui-egui/src/workflows/print_ui/draft.rs | 3 +- crates/ui-egui/tests/it/print.rs | 62 +++++++++++++++++++ 6 files changed, 123 insertions(+), 6 deletions(-) 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. From 4bbfd51718ebd6a6ce090e75861910b6f5705dee Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 10 Nov 2026 17:35:24 +0100 Subject: [PATCH 18/19] test(print): isolate the options worker wake from printer discovery --- crates/ui-egui/tests/it/print.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/tests/it/print.rs b/crates/ui-egui/tests/it/print.rs index 4a2ba39aff..cf5e284130 100644 --- a/crates/ui-egui/tests/it/print.rs +++ b/crates/ui-egui/tests/it/print.rs @@ -908,6 +908,10 @@ fn a_dead_options_read_wakes_an_idle_dialog() { use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; let mut h = harness(); + // A real default printer starts paper and duplex workers that can wake this dialog + // between the panic flag and the options sender being dropped. Only the options worker + // should be able to satisfy the wake assertion below. + h.state_mut().printer_source = Some(Arc::new(Vec::new)); let gate = Arc::new(Mutex::new(())); let hold = gate.lock().unwrap(); let wait = gate.clone(); @@ -928,7 +932,7 @@ fn a_dead_options_read_wakes_an_idle_dialog() { // The preview's page rasters render on the pool and ask for frames until they arrive; on a // loaded machine that outlasts `run`'s step budget, so the dialog is idle only after them. let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); - while h.state().render_pending() && std::time::Instant::now() < deadline { + while (h.state().render_pending() || h.state().print_draft.printers_loading) && std::time::Instant::now() < deadline { h.run_steps(1); std::thread::sleep(std::time::Duration::from_millis(5)); } From 67488306200c6cdfda905c2997ffe841588779da Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 10 Nov 2026 17:36:40 +0100 Subject: [PATCH 19/19] test(print): assert idle prerequisites before testing worker wake --- crates/ui-egui/tests/it/print.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/ui-egui/tests/it/print.rs b/crates/ui-egui/tests/it/print.rs index cf5e284130..ca8c8b5592 100644 --- a/crates/ui-egui/tests/it/print.rs +++ b/crates/ui-egui/tests/it/print.rs @@ -936,6 +936,8 @@ fn a_dead_options_read_wakes_an_idle_dialog() { h.run_steps(1); std::thread::sleep(std::time::Duration::from_millis(5)); } + assert!(!h.state().print_draft.printers_loading, "printer discovery finished before the idle assertion"); + assert!(!h.state().render_pending(), "the preview finished before the idle assertion"); h.run(); assert!(!h.ctx.has_requested_repaint(), "the dialog is idle while the read waits"); drop(hold);