From 2b2902194bf977c5a08625a9765cf7b851de93d2 Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Tue, 3 Nov 2026 10:18:01 +0530 Subject: [PATCH 01/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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); From 8559306f77418549fff6ffcf0391cfebdb759427 Mon Sep 17 00:00:00 2001 From: eliets2 Date: Tue, 10 Nov 2026 18:48:48 +0200 Subject: [PATCH 20/22] Do not send duplex to printers without two-sided support (#6673) On Windows the printer list reports whether a driver supports two-sided printing, but the job still asked for it anyway, so duplex jobs printed single-sided or misaligned on printers such as the reported Brother model. Leave duplex unset for a printer reported as not two-sided, so that printer keeps its own setting, while printers that support two-sided or report no capability keep the chosen setting. Test: duplex_is_not_sent_to_a_printer_without_two_sided failed before the change and passes after. Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> --- crates/ui-egui/src/workflows/print_ui.rs | 32 +++++++++++++++++++ .../ui-egui/src/workflows/print_ui/draft.rs | 15 +++++++-- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/workflows/print_ui.rs b/crates/ui-egui/src/workflows/print_ui.rs index c220a5bd2f..452fa10daa 100644 --- a/crates/ui-egui/src/workflows/print_ui.rs +++ b/crates/ui-egui/src/workflows/print_ui.rs @@ -832,6 +832,38 @@ mod tests { assert!(cut.settings(4, &[]).is_err()); } + /// #3208: long or short edge goes to a printer whose driver doesn't take two-sided settings + /// as no duplex setting at all, so the driver keeps its own instead of refusing the job; Off + /// still reaches it, a printer that can or whose support is unknown still gets the choice, + /// and a stale long-edge choice doesn't block cut and stack. The printers are listed as the + /// dialog lists them (#3554), drivers unasked, with each answer in `two_sided`. + #[test] + fn duplex_is_not_sent_to_a_printer_without_two_sided() { + let draft = |name: &str, duplex| PrintDraft { + printers: ["Office", "Duplexer", "Network"] + .map(|n| spool::Printer { name: n.into(), default: n == "Office", two_sided: None, description: String::new() }) + .to_vec(), + two_sided: [("Office".to_string(), Some(false)), ("Duplexer".to_string(), Some(true))].into_iter().collect(), + printer: Some(name.into()), + duplex, + ..PrintDraft::default() + }; + for duplex in [spool::Duplex::LongEdge, spool::Duplex::ShortEdge] { + assert_eq!(draft("Office", Some(duplex)).job("report.pdf").duplex, None, "{duplex:?} to a printer without two-sided"); + assert_eq!(draft("Duplexer", Some(duplex)).job("report.pdf").duplex, Some(duplex)); + assert_eq!(draft("Network", Some(duplex)).job("report.pdf").duplex, Some(duplex), "unknown support keeps the choice"); + } + assert_eq!(draft("Office", Some(spool::Duplex::Off)).job("report.pdf").duplex, Some(spool::Duplex::Off), "Off still reaches it"); + assert_eq!(draft("Office", None).job("report.pdf").duplex, None); + // A long-edge choice left from another printer doesn't block cut and stack on this one. + let mut cut = draft("Office", Some(spool::Duplex::LongEdge)); + (cut.handling, cut.order) = (Handling::Multiple, PageOrder::CutStack); + assert!(cut.settings(4, &[]).is_ok()); + assert_eq!(cut.job("report.pdf").duplex, Some(spool::Duplex::Off)); + cut.printer = Some("Duplexer".into()); + assert!(cut.settings(4, &[]).is_err(), "still refused where it would print two-sided"); + } + /// #5772: a booklet's both sides ask the printer for the short edge, unless its driver doesn't /// take two-sided settings: it would refuse the job, so its own setting (manual two-sided, say) /// prints it. One side on its own stays one-sided. diff --git a/crates/ui-egui/src/workflows/print_ui/draft.rs b/crates/ui-egui/src/workflows/print_ui/draft.rs index 5b02687b62..e74cd4e069 100644 --- a/crates/ui-egui/src/workflows/print_ui/draft.rs +++ b/crates/ui-egui/src/workflows/print_ui/draft.rs @@ -255,7 +255,7 @@ impl PrintDraft { area: Some(area), }); } - if self.cut_and_stack() && matches!(self.duplex, Some(spool::Duplex::LongEdge | spool::Duplex::ShortEdge)) { + if self.cut_and_stack() && matches!(self.chosen_duplex(), Some(spool::Duplex::LongEdge | spool::Duplex::ShortEdge)) { return Err("Cut and stack needs Two-sided: Off. Print single-sided sheets.".into()); } let range = match self.which { @@ -322,6 +322,17 @@ impl PrintDraft { p.two_sided.or_else(|| self.two_sided.get(&p.name).copied().flatten()) } + /// The Two-sided choice as the chosen printer can take it: long or short edge is left out for + /// a printer whose driver doesn't take two-sided settings from other programs, so its own + /// setting applies instead of the job being refused (#3208). Off and the printer's default + /// stay as chosen: one-sided is accepted by every driver. + fn chosen_duplex(&self) -> Option { + match self.duplex { + Some(spool::Duplex::LongEdge | spool::Duplex::ShortEdge) if self.printer_two_sided() == Some(false) => None, + chosen => chosen, + } + } + /// The spooler job for this print: the dialog's copies, two-sided and colour settings, the /// driver options changed from the printer's defaults, and the chosen paper. pub fn job(&self, title: &str) -> spool::Job { @@ -337,7 +348,7 @@ impl PrintDraft { } else if self.handling == Handling::Booklet { print::booklet_job_duplex(self.booklet_subset, self.printer_two_sided()) } else { - self.duplex + self.chosen_duplex() }, scaling: spool::Scaling::Exact, grayscale: print::driver_needs_grayscale(self.grayscale, self.raster()), From e03d5a1c298dd34dc758a4aa63d581bf5184a0c8 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 10 Nov 2026 18:11:33 +0100 Subject: [PATCH 21/22] Render: an isolated CMYK group of any size blends in CMYK, in bands (#6885) * Render: an isolated CMYK group of any size blends in CMYK, in bands hayro patch (27) blended an isolated DeviceCMYK transparency group in CMYK only up to 2^24 device pixels (a 4096 x 4096 square); a larger one was blended in RGB. Print as image renders a whole page at once, up to 8192 px on the long side, so a page enlarged to 11 x 17 in at 600 dpi (about 43 MP) blended any group covering most of it in RGB, and a print test file's CMYK blend patches drew their X's. The same applied to exporting a large page as an image. A larger group is now blended in bands of whole rows, each within the old limit: both ink passes and the tint conversion run per band, so their memory stays as before, and only the finished group (4 bytes a pixel) is kept whole. Every band is blended before any is drawn, so a group a pass has no tints for is still drawn as before, whole. The regression test lowers the band to 7 rows (set_cmyk_band_pixels, hidden, per thread like set_skip_offscreen_paths) and checks the banded group matches the whole one across an anti-aliased edge, and is CMYK's green where magenta is under black with Difference. * Test: CMYK green through the profile is (0, 166, 81); check green against RGB's magenta --- .../src/raster/tests/isolated_cmyk_groups.rs | 45 +++++++ vendor/hayro/src/lib.rs | 3 + vendor/hayro/src/renderer.rs | 116 ++++++++++++------ 3 files changed, 126 insertions(+), 38 deletions(-) diff --git a/crates/render/src/raster/tests/isolated_cmyk_groups.rs b/crates/render/src/raster/tests/isolated_cmyk_groups.rs index c9cc9aa492..aa15019277 100644 --- a/crates/render/src/raster/tests/isolated_cmyk_groups.rs +++ b/crates/render/src/raster/tests/isolated_cmyk_groups.rs @@ -280,3 +280,48 @@ fn the_color_blend_mode_over_an_empty_cmyk_group_keeps_every_k_level() { } assert!(wrong.is_empty(), "K levels drawn wrong (level, plain, blended): {wrong:?}"); } + +/// A group larger than the renderer blends in CMYK at once (hayro patch (27): 2^24 device +/// pixels) used to be blended in RGB instead: a page enlarged to 11 x 17 in for Print as image +/// at 600 dpi drew the X's of a print test file's CMYK blend patches. It is now blended in bands +/// of whole rows. Here the band is lowered to a few rows, so the group is blended in many +/// bands, across the anti-aliased edge of a triangle as well: the result is the same as in one +/// piece, and still CMYK's (magenta under black with Difference is green). +#[test] +fn a_cmyk_group_blended_in_bands_is_the_group_blended_whole() { + let group = "0 1 0 0 k 0 0 m 200 0 l 0 200 l f /Diff gs 0 0 0 1 k 0 0 200 200 re f"; + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 220 220] /Contents 4 0 R /Resources << /XObject << /X0 5 0 R >> >> >> endobj +4 0 obj << /Length 28 >> stream +q 1 0 0 1 10 10 cm /X0 Do Q +endstream endobj +5 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency /CS /DeviceCMYK /I true >> /Resources << /ExtGState << /Diff << /BM /Difference >> >> >> /Length {} >> stream +{group} +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + group.len() + ); + let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); + let request = RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 2.0, tag: 0, ..Default::default() }; + let whole = r.render(request); + // 400 device pixels a row: bands of 7 rows, the last one shorter. + hayro::set_cmyk_band_pixels(Some(2_800)); + let banded = r.render(request); + hayro::set_cmyk_band_pixels(None); + assert!(whole.error.is_none() && banded.error.is_none(), "{:?} {:?}", whole.error, banded.error); + assert_eq!((whole.width, whole.height), (banded.width, banded.height)); + let worst = whole.rgba.iter().zip(banded.rgba.iter()).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0); + assert!(worst <= 1, "bands differ from the whole group by up to {worst}"); + // Inside the triangle (device (100, 340), the group's point (40, 40)): magenta under black. + let at = |p: &RenderedPage, x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..3].to_vec(); + for page in [&whole, &banded] { + let rgb = at(page, 100, 340); + // CMYK's 1 0 1 0 through the CMYK profile is (0, 166, 81); blended in RGB it would be + // magenta, red and blue high and green low. + assert!(rgb[1] > 120 && rgb[0] < 60 && rgb[2] < 120, "blended in CMYK, green: {rgb:?}"); + } +} diff --git a/vendor/hayro/src/lib.rs b/vendor/hayro/src/lib.rs index 73730a07a7..1c13faffda 100644 --- a/vendor/hayro/src/lib.rs +++ b/vendor/hayro/src/lib.rs @@ -65,6 +65,9 @@ pub use renderer::{image_resampling_size, tiling_cell_pixels, tiling_cell_scale, /// PdfCraft patch: exported for its regression test. #[doc(hidden)] pub use renderer::set_skip_offscreen_paths; +/// PdfCraft patch (27): exported for its regression test. +#[doc(hidden)] +pub use renderer::set_cmyk_band_pixels; /// PdfCraft patch (30): exported for its regression tests. #[doc(hidden)] pub use renderer::{OffscreenDraws, offscreen_draws}; diff --git a/vendor/hayro/src/renderer.rs b/vendor/hayro/src/renderer.rs index 7a9462b63c..c8640adb14 100644 --- a/vendor/hayro/src/renderer.rs +++ b/vendor/hayro/src/renderer.rs @@ -815,11 +815,27 @@ enum PaintSetup { Nothing, } -/// PdfCraft patch (27): the largest group, in device pixels, blended in CMYK (2^24, a 4096 × -/// 4096 square). Each pixel costs about 20 bytes more than drawing the group as before (two -/// pass canvases, its tints, their RGB and the result); a larger group is drawn as before. +/// PdfCraft patch (27): the most device pixels of a group blended in CMYK at once (2^24, a 4096 × +/// 4096 square). Each costs about 16 bytes while its band is blended (two pass canvases, its +/// tints and their RGB); a larger group is blended in bands of whole rows under it, and only +/// the finished group, 4 bytes a pixel, is kept whole. Before, a larger group was blended in RGB +/// instead: a page enlarged to 11 x 17 in for Print as image at 600 dpi drew the X's of a print +/// test file's CMYK blend patches when one group covered most of it. const MAX_CMYK_GROUP_PIXELS: u64 = 1 << 24; +thread_local! { + /// PdfCraft patch (27): the band size [`MAX_CMYK_GROUP_PIXELS`] sets, on this thread. Only + /// PdfCraft's regression test lowers it, to blend a small page's groups in many bands. + static CMYK_BAND_PIXELS: std::cell::Cell = const { std::cell::Cell::new(MAX_CMYK_GROUP_PIXELS) }; +} + +/// PdfCraft patch (27): exported for its regression test (see [`CMYK_BAND_PIXELS`]); `None` +/// restores [`MAX_CMYK_GROUP_PIXELS`]. +#[doc(hidden)] +pub fn set_cmyk_band_pixels(pixels: Option) { + CMYK_BAND_PIXELS.with(|p| p.set(pixels.unwrap_or(MAX_CMYK_GROUP_PIXELS).max(1))); +} + pub(crate) struct Renderer { pub(crate) ctx: RenderContext, /// PdfCraft patch: when set, solid text and line-art paint colours are remapped @@ -1743,6 +1759,46 @@ impl Renderer { Some(pix) } + /// PdfCraft patch (27): rows `offset.1` .. `offset.1 + height` of `group` blended in CMYK, as + /// a `width` × `height` premultiplied pixmap whose corner is device point `offset`; `None` + /// when the group holds something a pass has no tints for, or its tints can't be converted. + fn cmyk_group_band( + &self, + group: &hayro_interpret::CmykGroup<'_, '_>, + offset: (f64, f64), + width: u16, + height: u16, + ) -> Option { + let cmy = self.ink_pass(group, InkPass::Cmy, offset, width, height)?; + let k = self.ink_pass(group, InkPass::K, offset, width, height)?; + + // A tint is a pass's premultiplied colour un-premultiplied and complemented. + let ink = |premul: u8, alpha: u8| { + if alpha == 0 { + return 0; + } + let straight = (u32::from(premul) * 255 + u32::from(alpha) / 2) / u32::from(alpha); + 255 - straight.min(255) as u8 + }; + let pixels = usize::from(width) * usize::from(height); + let mut tints = Vec::with_capacity(pixels * 4); + for (p, q) in cmy.data().iter().zip(k.data()) { + // Both passes draw the same shapes at the same opacities, so they cover the same + // pixels by the same amounts; each pass's own alpha un-premultiplies its colour. + tints.extend_from_slice(&[ink(p.r, p.a), ink(p.g, p.a), ink(p.b, p.a), ink(q.r, q.a)]); + } + let mut rgb = vec![0u8; pixels * 3]; + hayro_interpret::color::cmyk_to_srgb8(&tints, &mut rgb)?; + + let mut result = offscreen_pixmap(width, height, false); + for ((px, rgb), coverage) in result.data_mut().iter_mut().zip(rgb.chunks_exact(3)).zip(cmy.data()) { + let a = u32::from(coverage.a); + let premul = |v: u8| ((u32::from(v) * a + 127) / 255) as u8; + *px = PremulRgba8 { r: premul(rgb[0]), g: premul(rgb[1]), b: premul(rgb[2]), a: coverage.a }; + } + Some(result) + } + /// PdfCraft patch: remaps a solid paint colour through `color_replacement`, if one is /// set. `Paint::Pattern` (images, shadings, tiling patterns) always passes through /// untouched. @@ -2444,42 +2500,26 @@ impl<'a> Device<'a> for Renderer { if width == 0 || height == 0 { return true; } - if u64::from(width) * u64::from(height) > MAX_CMYK_GROUP_PIXELS { - return false; - } - - let Some(cmy) = self.ink_pass(group, InkPass::Cmy, (x0, y0), width, height) else { - return false; - }; - let Some(k) = self.ink_pass(group, InkPass::K, (x0, y0), width, height) else { - return false; - }; - - // A tint is a pass's premultiplied colour un-premultiplied and complemented. - let ink = |premul: u8, alpha: u8| { - if alpha == 0 { - return 0; - } - let straight = (u32::from(premul) * 255 + u32::from(alpha) / 2) / u32::from(alpha); - 255 - straight.min(255) as u8 - }; - let pixels = usize::from(width) * usize::from(height); - let mut tints = Vec::with_capacity(pixels * 4); - for (p, q) in cmy.data().iter().zip(k.data()) { - // Both passes draw the same shapes at the same opacities, so they cover the same - // pixels by the same amounts; each pass's own alpha un-premultiplies its colour. - tints.extend_from_slice(&[ink(p.r, p.a), ink(p.g, p.a), ink(p.b, p.a), ink(q.r, q.a)]); - } - let mut rgb = vec![0u8; pixels * 3]; - if hayro_interpret::color::cmyk_to_srgb8(&tints, &mut rgb).is_none() { - return false; - } - + // Bands of whole rows, each within the band size: a group of any size blends in CMYK. + // Every band is blended before any is drawn, so a group a pass has no tints for in one + // band is drawn as before, whole. + let band = (CMYK_BAND_PIXELS.with(std::cell::Cell::get) / u64::from(width)).clamp(1, u64::from(height)); + // At most `height`, so it fits a u16. + let band = band as u16; let mut result = offscreen_pixmap(width, height, false); - for ((px, rgb), coverage) in result.data_mut().iter_mut().zip(rgb.chunks_exact(3)).zip(cmy.data()) { - let a = u32::from(coverage.a); - let premul = |v: u8| ((u32::from(v) * a + 127) / 255) as u8; - *px = PremulRgba8 { r: premul(rgb[0]), g: premul(rgb[1]), b: premul(rgb[2]), a: coverage.a }; + let row = usize::from(width); + let mut top = 0u16; + while top < height { + let rows = band.min(height - top); + let Some(piece) = self.cmyk_group_band(group, (x0, y0 + f64::from(top)), width, rows) else { + return false; + }; + let start = usize::from(top) * row; + let Some(dest) = result.data_mut().get_mut(start..start + usize::from(rows) * row) else { + return false; + }; + dest.copy_from_slice(piece.data()); + top += rows; } // The group's own soft mask and blend mode are on its layer; what the contents set From 92b965dba324a15c3c3a48e5d7b1e78fb5e4e6bc Mon Sep 17 00:00:00 2001 From: Kyle Kassen <85040622+KyleKassen@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:26:50 -0600 Subject: [PATCH 22/22] M10: preserve annotation layers in vector print output (#5019) * M10: preserve annotation layers in vector printing (#5005) * #5019 test: a Popup is the annotation that never prints; a Link prints since #5028 --------- Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> --- ROADMAP.md | 6 + crates/print/README.md | 5 + crates/print/src/imposition.rs | 94 +++- crates/print/src/tests.rs | 1 + crates/print/src/tests/optional_content.rs | 502 +++++++++++++++++++++ docs/roadmap.md | 5 + parity/acrobat-features.toml | 4 +- 7 files changed, 612 insertions(+), 5 deletions(-) create mode 100644 crates/print/src/tests/optional_content.rs diff --git a/ROADMAP.md b/ROADMAP.md index ae6297e910..b81f8fa519 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -40,6 +40,7 @@ > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M1: refresh object-generation reference identity against current upstream; estimates unchanged) **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away +> **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: vector printing preserves annotation optional-content membership; estimates unchanged) > **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M6, PR #4166/#4088: preserve automatic-field Print suppression, explicit script printing and document-error provenance on upstream 0.15; retain the integrated craft-libs revision; estimates unchanged) > > @@ -252,6 +253,9 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log - **2026-11-09 (M10, #5130):** Windows Printer default sends native two-sided bindings only when automatic duplex support is confirmed, preserving manual driver defaults (#5130/#5772); physical output remains unqualified. Both Windows submission paths preserve DLL guards and current spooler fixes. Review follow-up validates both duplex scopes through craft-print 9fe50c94 and corrects CI formatting. Estimates unchanged. +- **2026-10-27 (M10, #5005):** Vector printing retains each annotation's optional-content membership when drawing its appearance. + Shared appearances and existing layer state remain independent; estimates unchanged. + - **2026-10-29 (M10, #5029):** Vector printing also honours the implicit NoRotate behavior of existing Text appearances when the flag is clear. Print eligibility, source dictionaries and shared appearance streams remain unchanged. @@ -864,6 +868,8 @@ Newest first: the date, what moved, and the new overall estimate. ## Revision history +**2026-10-27 · minor:** M10 #5005: vector printing preserves annotation optional-content membership. +Source resources and shared appearances remain unchanged. **2026-10-31 · patch:** M6/PR #4166 integrated main f137a289 (0.16.1), preserving both roadmap histories. Automatic Keystroke, Validate, Calculate and Format scripts still suppress Print alongside ShowPanel/SaveAs; explicit button and console Print requests, field effects and document-error provenance are retained. The three engine regressions and headless automation regression remain in their current modules. Manifests, lockfile and CI match incoming main. Windows x64 / Rust 1.95 validation: 761 engine/automation tests passed, zero failed and four existing ignored; formatting, scoped all-target Clippy with -D warnings, layers/assets/parity and the engine/automation WebAssembly check passed. A fresh CLI build and the 13-step synthetic form exercise passed; the inspected render shows Qty=4 and Total=8, and explicit button/console Print requests are retained. Main's 100 headless parity notices, five renderer WebAssembly warnings and three optional egui patch notices remain. Estimates unchanged. **2026-10-29 · minor:** M6/PR #4166 refreshed against upstream 0.15. Automatic field scripts still suppress Print with a console diagnostic alongside ShowPanel/SaveAs filtering, and explicit button/console scripts retain their requests and document-error provenance. The three engine regressions, headless regression and parity evidence remain in the current modules. Dependency manifests retain main's integrated craft-libs revision, including #366. Estimates unchanged. diff --git a/crates/print/README.md b/crates/print/README.md index 7b0fd93f2a..6fa2dc9367 100644 --- a/crates/print/README.md +++ b/crates/print/README.md @@ -54,6 +54,11 @@ Each source page becomes a Form XObject (its content wrapped in q/Q, plus the pr annotations); sheets place them with a clip. The result is a fresh, unencrypted, garbage-collected file (callers check the print permission). Annotation appearances receive unused XObject names, preserving existing page resources even when they use the same naming prefix. +Vector printing also carries each annotation's optional-content membership onto its own appearance invocation. +Hidden groups, membership policies, visibility expressions and existing layer overrides therefore remain effective in the generated PDF, including when appearances are shared. +Malformed top-level membership or required resource containers produce a print error instead of an unguarded appearance. +Nested policy data is preserved without a new evaluator; readers remain responsible for optional-content semantics. +The separate on-screen and Print as image annotation-visibility gap is tracked in #3572 / #3624. Appearance placement keeps the full PDF number precision, including small coefficients needed by large appearance bounding boxes or matrices. Existing printable NoRotate appearances keep their orientation and upper-left rectangle anchor when the source page is rotated. Text annotations use this upright placement even with the NoRotate flag clear (ISO 32000-1 section 12.5.6.4). diff --git a/crates/print/src/imposition.rs b/crates/print/src/imposition.rs index d9f1e32043..9a225a0db7 100644 --- a/crates/print/src/imposition.rs +++ b/crates/print/src/imposition.rs @@ -163,6 +163,56 @@ pub(crate) fn appearance_matrix(doc: &Document, form: &Dict, rect: [f64; 4]) -> Some(a) } +/// Resolve optional-content resources without turning a loading failure or reference cycle +/// into an absent entry. Keep the terminal object's identity for catalog OCG state. +fn optional_object(doc: &Document, value: &Object) -> Result<(Option, std::sync::Arc), PrintError> { + let mut object = std::sync::Arc::new(value.clone()); + let mut reference = None; + let mut seen = std::collections::HashSet::new(); + while let Object::Ref(r) = &*object { + if seen.len() >= 64 || !seen.insert(*r) { + return Err(PrintError::Invalid("an optional-content resource has a cyclic reference or more than 64 references".into())); + } + reference = Some(*r); + object = doc.try_get(r.num)?; + } + Ok((reference, object)) +} + +/// Preserve the complete membership rather than evaluating visibility here. The reader owns +/// policy/VE evaluation; the annotation and its appearance can have independent memberships. +fn annotation_membership(doc: &mut Document, annotation: &Dict) -> Result, PrintError> { + let Some(value) = annotation.get(b"OC") else { return Ok(None) }; + let (reference, object) = optional_object(doc, value)?; + let dict = match &*object { + Object::Null => return Ok(None), // Null and undefined references are absent PDF dictionary entries. + Object::Dict(dict) => dict, + _ => return Err(PrintError::Invalid("an annotation's optional-content entry is not an OCG or OCMD dictionary".into())), + }; + let kind = dict.get(b"Type").map(|value| optional_object(doc, value)).transpose()?; + match kind.as_ref().and_then(|(_, object)| object.as_name()) { + Some(b"OCG") => reference.map(Some).ok_or_else(|| PrintError::Invalid("an annotation's optional-content group must be indirect".into())), + Some(b"OCMD") => { + // Named Properties are indirect for interoperable OCG identity and for the renderer's + // resource path. An OCMD has no group identity; promoting its intact dict is harmless. + Ok(Some(reference.unwrap_or_else(|| doc.add(Object::Dict(dict.clone()))))) + } + _ => Err(PrintError::Invalid("an annotation's optional-content dictionary must have Type OCG or OCMD".into())), + } +} + +/// Clone existing marked-content properties, including unrelated entries. Never replace an +/// unreadable map with an empty one: doing so can expose the page's own optional content. +fn optional_properties(doc: &Document, resources: &Dict) -> Result { + let Some(value) = resources.get(b"Properties") else { return Ok(Dict::new()) }; + let (_, object) = optional_object(doc, value)?; + match &*object { + Object::Null => Ok(Dict::new()), + Object::Dict(dict) => Ok(dict.clone()), + _ => Err(PrintError::Invalid("cannot preserve annotation layers: page Properties is not a dictionary".into())), + } +} + /// ISO 32000-1 section 12.5.3: counterrotate the fitted appearance about Rect's upper-left /// corner in default user space. Crop, UserUnit and sheet transforms remain outside it. fn no_rotate_placement(placement: Matrix, rect: [f64; 4], rotation: i64) -> Option { @@ -189,7 +239,8 @@ pub(crate) fn page_form( ) -> Result { let page = pages.get(index).ok_or_else(|| PrintError::Invalid(format!("there is no page {}", index.saturating_add(1))))?; let crop = page.crop(doc); - let mut res = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); + let resources = page.dict.get(b"Resources").map(|value| optional_object(doc, value)).transpose(); + let mut res = resources.as_ref().ok().and_then(|value| value.as_ref()).and_then(|(_, object)| object.as_dict()).cloned().unwrap_or_default(); let mut body = if content == Content::FormFieldsOnly { Vec::new() } else { @@ -205,6 +256,8 @@ pub(crate) fn page_form( } let annots = page.dict.get(b"Annots").and_then(|a| doc.resolve(a).as_array().cloned()).unwrap_or_default(); let mut xobjects = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default(); + let mut properties = None; + let mut property_index = 0usize; let mut k = 0usize; for a in annots { let Some(d) = doc.resolve(&a).as_dict().cloned() else { continue }; @@ -232,6 +285,31 @@ pub(crate) fn page_form( } else { m }; + let membership = annotation_membership(doc, &d)?; + let property = if let Some(membership) = membership { + if properties.is_none() { + match &resources { + Err(error) => return Err(PrintError::Invalid(format!("cannot preserve annotation layers: {error}"))), + Ok(Some((_, object))) if !matches!(&**object, Object::Null | Object::Dict(_)) => { + return Err(PrintError::Invalid("cannot preserve annotation layers: page Resources is not a dictionary".into())); + } + _ => {} + } + properties = Some(optional_properties(doc, &res)?); + } + let properties = properties.get_or_insert_with(Dict::new); + let name = loop { + let name = format!("PCPrOC{property_index}"); + property_index = property_index.checked_add(1).ok_or_else(|| PrintError::Invalid("too many annotation layer resources".into()))?; + if !properties.contains(name.as_bytes()) { + break name; + } + }; + properties.set(name.as_bytes().to_vec(), Object::Ref(membership)); + Some(name) + } else { + None + }; let ap = if !form.contains(b"Subtype") && let Object::Stream(stream) = &*doc.get(ap) { @@ -259,6 +337,9 @@ pub(crate) fn page_form( } }; let [a0, b0, c0, d0, e0, f0] = m.0; + if let Some(name) = &property { + body.extend(format!("/OC /{name} BDC\n").bytes()); + } body.extend( format!( "q {} {} {} {} {} {} cm /{name} Do Q\n", @@ -271,16 +352,23 @@ pub(crate) fn page_form( ) .bytes(), ); + if property.is_some() { + body.extend_from_slice(b"EMC\n"); + } xobjects.set(name.into_bytes(), Object::Ref(ap)); } // With no appearance added the form keeps the page's own Resources, shared by reference, - // rather than a deep copy per printed page. + // rather than a deep copy per printed page. An appearance in a layer (#5005) adds its + // membership to the copy's Properties, so a page with one is never shared. let resources = match page.dict.get(b"Resources") { - Some(r) if k == 0 && doc.resolve(r).as_dict().is_some() => r.clone(), + Some(r) if k == 0 && properties.is_none() && doc.resolve(r).as_dict().is_some() => r.clone(), _ => { if !xobjects.is_empty() { res.set(b"XObject".to_vec(), Object::Dict(xobjects)); } + if let Some(properties) = properties { + res.set(b"Properties".to_vec(), Object::Dict(properties)); + } Object::Dict(res) } }; diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index 1d5ce2ca26..9bcb7e1ab4 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -1452,6 +1452,7 @@ fn driver_options_take_the_ppds_own_names_in_the_ui_language() { mod appearances; mod font_names; +mod optional_content; #[test] fn repeated_content_references_share_one_decode_budget() { diff --git a/crates/print/src/tests/optional_content.rs b/crates/print/src/tests/optional_content.rs new file mode 100644 index 0000000000..b8c6caee15 --- /dev/null +++ b/crates/print/src/tests/optional_content.rs @@ -0,0 +1,502 @@ +use super::*; +use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RenderedPage, RequestKind}; + +fn numbers(values: &[f64]) -> Object { + Object::Array(values.iter().copied().map(Object::Real).collect()) +} + +fn layer_fixture(off: bool) -> (Document, ObjRef, ObjRef, ObjRef) { + let mut doc = fixture(1); + let page = pdfcraft_model::pages(&doc)[0].obj; + let group = doc.add(Object::Dict(Dict::from_iter([ + (b"Type".to_vec(), Object::name("OCG")), + (b"Name".to_vec(), Object::String(pdfcraft_cos::PdfString::text("Annotation Layer"))), + ]))); + let properties = Dict::from_iter([ + (b"OCGs".to_vec(), Object::Array(vec![Object::Ref(group)])), + ( + b"D".to_vec(), + Object::Dict(Dict::from_iter([ + (b"BaseState".to_vec(), Object::name("ON")), + (b"OFF".to_vec(), Object::Array(if off { vec![Object::Ref(group)] } else { vec![] })), + ])), + ), + ]); + doc.update_dict(doc.root().unwrap(), |d| d.set(b"OCProperties".to_vec(), Object::Dict(properties))).unwrap(); + let form = Dict::from_iter([ + (b"Subtype".to_vec(), Object::name("Form")), + (b"BBox".to_vec(), numbers(&[0.0, 0.0, 20.0, 10.0])), + (b"Resources".to_vec(), Object::Dict(Dict::new())), + ]); + let appearance = doc.add(Object::Stream(Stream::flate(form, b"0 1 0 rg 0 0 20 10 re f"))); + let annotation = doc.add(Object::Dict(Dict::from_iter([ + (b"Subtype".to_vec(), Object::name("Square")), + (b"F".to_vec(), Object::Int(4)), + (b"Rect".to_vec(), numbers(&[10.0, 20.0, 50.0, 40.0])), + (b"OC".to_vec(), Object::Ref(group)), + (b"AP".to_vec(), Object::Dict(Dict::from_iter([(b"N".to_vec(), Object::Ref(appearance))]))), + ]))); + let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), b"0 0 1 rg 0 0 8 8 re f"))); + doc.update_dict(page, |d| { + d.set(b"MediaBox".to_vec(), numbers(&[0.0, 0.0, 100.0, 100.0])); + d.set(b"Resources".to_vec(), Object::Dict(Dict::new())); + d.set(b"Contents".to_vec(), Object::Ref(contents)); + d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(annotation)])); + }) + .unwrap(); + (doc, group, annotation, appearance) +} + +fn render(bytes: Vec) -> RenderedPage { + let page = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + tile: None, + scale: 1.0, + tag: 0, + preview: false, + }); + assert_eq!(page.error, None); + assert!(page.warnings.is_empty(), "{:?}", page.warnings); + assert_eq!((page.width, page.height), (100, 100)); + page +} + +fn print(doc: &Document) -> Result, PrintError> { + impose(doc, &Settings { pages: vec![0], paper: (100.0, 100.0), layout: Layout::Size(SizeMode::Actual), ..Settings::default() }) +} + +#[test] +fn vector_print_preserves_annotation_layer_membership() { + for off in [false, true] { + let (doc, _, _, _) = layer_fixture(off); + let before = write_full(&doc, &SaveOptions::default()).unwrap(); + let printed = print(&doc).unwrap(); + let reopened = Document::open(Arc::new(printed.clone())).unwrap(); + assert_eq!(pdfcraft_model::pages(&reopened).len(), 1); + let page = render(printed); + let pixel = |x: usize, y: usize| &page.rgba[(y * 100 + x) * 4..(y * 100 + x + 1) * 4]; + assert_eq!(pixel(30, 70), if off { [255, 255, 255, 255] } else { [0, 255, 0, 255] }, "layer off={off}"); + assert_eq!(pixel(4, 96), [0, 0, 255, 255], "ordinary page artwork"); + assert_eq!(write_full(&doc, &SaveOptions::default()).unwrap(), before); + } +} + +fn saved(doc: &Document) -> Vec { + write_full(doc, &SaveOptions::default()).unwrap() +} + +fn pixel(page: &RenderedPage, x: usize, y: usize) -> &[u8] { + let offset = (y * page.width as usize + x) * 4; + &page.rgba[offset..offset + 4] +} + +fn assert_appearance(page: &RenderedPage, visible: bool) { + assert_eq!(pixel(page, 30, 70), if visible { [0, 255, 0, 255] } else { [255, 255, 255, 255] }); + assert_eq!(pixel(page, 4, 96), [0, 0, 255, 255], "ordinary page artwork"); +} + +fn add_group(doc: &mut Document, name: &str, off: bool) -> ObjRef { + let group = doc.add(Object::Dict(Dict::from_iter([ + (b"Type".to_vec(), Object::name("OCG")), + (b"Name".to_vec(), Object::String(pdfcraft_cos::PdfString::text(name))), + ]))); + let root = doc.root().unwrap(); + let mut properties = doc.resolve(doc.get(root).as_dict().unwrap().get(b"OCProperties").unwrap()).as_dict().unwrap().clone(); + let mut groups = properties.get(b"OCGs").unwrap().as_array().unwrap().clone(); + groups.push(Object::Ref(group)); + properties.set(b"OCGs".to_vec(), Object::Array(groups)); + if off { + let mut config = properties.get(b"D").unwrap().as_dict().unwrap().clone(); + let mut hidden = config.get(b"OFF").unwrap().as_array().unwrap().clone(); + hidden.push(Object::Ref(group)); + config.set(b"OFF".to_vec(), Object::Array(hidden)); + properties.set(b"D".to_vec(), Object::Dict(config)); + } + doc.update_dict(root, |d| d.set(b"OCProperties".to_vec(), Object::Dict(properties))).unwrap(); + group +} + +fn membership(groups: Vec, policy: Option<&str>, expression: Option>) -> Dict { + let mut dict = Dict::from_iter([ + (b"Type".to_vec(), Object::name("OCMD")), + (b"OCGs".to_vec(), Object::Array(groups)), + (b"PrivatePolicy".to_vec(), Object::Array(vec![Object::Int(19), Object::name("KeepMe")])), + ]); + if let Some(policy) = policy { + dict.set(b"P".to_vec(), Object::name(policy)); + } + if let Some(expression) = expression { + dict.set(b"VE".to_vec(), Object::Array(expression)); + } + dict +} + +fn printed_form(doc: &Document) -> Dict { + let page = pdfcraft_model::pages(doc)[0].dict.clone(); + let resources = doc.resolve(page.get(b"Resources").unwrap()); + let xobjects = doc.resolve(resources.as_dict().unwrap().get(b"XObject").unwrap()); + doc.resolve(xobjects.as_dict().unwrap().get(b"P0").unwrap()).as_dict().unwrap().clone() +} + +fn form_properties(doc: &Document) -> Dict { + let form = printed_form(doc); + let resources = doc.resolve(form.get(b"Resources").unwrap()); + doc.resolve(resources.as_dict().unwrap().get(b"Properties").unwrap()).as_dict().unwrap().clone() +} + +#[test] +fn vector_print_preserves_ocmd_policies_and_visibility_expressions() { + for indirect in [false, true] { + for group_shape in 0..3 { + let single_group = group_shape != 0; + for (policy, visible) in [("AllOn", false), ("AnyOn", !single_group), ("AnyOff", true), ("AllOff", single_group)] { + let (mut doc, off, annotation, appearance) = layer_fixture(true); + let on = add_group(&mut doc, "Visible", false); + let groups = if single_group { vec![Object::Ref(off)] } else { vec![Object::Ref(off), Object::Ref(on)] }; + let mut dict = membership(groups, Some(policy), None); + if group_shape == 2 { + dict.set(b"OCGs".to_vec(), Object::Ref(off)); + } + let private = dict.get(b"PrivatePolicy").unwrap().clone(); + let oc = if indirect { Object::Ref(doc.add(Object::Dict(dict))) } else { Object::Dict(dict) }; + doc.update_dict(annotation, |d| d.set(b"OC".to_vec(), oc)).unwrap(); + let before = saved(&doc); + let original_appearance = doc.get(appearance); + let printed = print(&doc).unwrap(); + assert_appearance(&render(printed.clone()), visible); + let reopened = Document::open(Arc::new(printed)).unwrap(); + let props = form_properties(&reopened); + let membership = props.get(b"PCPrOC0").unwrap(); + assert!(membership.as_ref().is_some(), "direct OCMD must be promoted for named-resource rendering"); + assert_eq!(reopened.resolve(membership).as_dict().unwrap().get(b"PrivatePolicy"), Some(&private)); + assert_appearance(&render(print(&reopened).unwrap()), visible); + assert_eq!(doc.get(appearance), original_appearance); + assert_eq!(saved(&doc), before, "policy={policy}, indirect={indirect}, single={single_group}"); + } + } + for case in 0..6 { + let (mut doc, off, annotation, _) = layer_fixture(true); + let on = add_group(&mut doc, "Visible", false); + let mut dict = membership(vec![Object::Ref(off), Object::Ref(on)], None, None); + let visible = match case { + 0 => true, // Missing P defaults to AnyOn. + 1 => { + dict.set(b"P".to_vec(), Object::name("AllOn")); + dict.set(b"VE".to_vec(), Object::Array(vec![Object::name("Not"), Object::Ref(off)])); + true // VE overrides the false AllOn policy. + } + 2 => { + dict.set(b"P".to_vec(), Object::name("AnyOn")); + dict.set(b"VE".to_vec(), Object::Array(vec![Object::name("Not"), Object::Ref(on)])); + false // VE also overrides a true policy. + } + 3 => { + dict.remove(b"OCGs"); + true // Missing OCGs is a valid no-op. + } + 4 => { + dict.set(b"OCGs".to_vec(), Object::Array(vec![])); + true + } + _ => { + dict.remove(b"OCGs"); + dict.set(b"VE".to_vec(), Object::Array(vec![Object::name("Not"), Object::Ref(on)])); + false // VE-only membership is valid. + } + }; + let oc = if indirect { Object::Ref(doc.add(Object::Dict(dict))) } else { Object::Dict(dict) }; + doc.update_dict(annotation, |d| d.set(b"OC".to_vec(), oc)).unwrap(); + let before = saved(&doc); + assert_appearance(&render(print(&doc).unwrap()), visible); + assert_eq!(saved(&doc), before, "OCMD case={case}, indirect={indirect}"); + } + } +} + +#[test] +fn annotation_and_appearance_layers_are_independent_and_shared_appearances_stay_shared() { + for annotation_off in [false, true] { + for appearance_off in [false, true] { + let (mut doc, _, annotation, appearance) = layer_fixture(annotation_off); + let appearance_group = add_group(&mut doc, "Appearance", appearance_off); + doc.update_dict(appearance, |d| d.set(b"OC".to_vec(), Object::Ref(appearance_group))).unwrap(); + let before = saved(&doc); + let first = print(&doc).unwrap(); + assert_appearance(&render(first.clone()), !annotation_off && !appearance_off); + let reopened = Document::open(Arc::new(first)).unwrap(); + assert_appearance(&render(print(&reopened).unwrap()), !annotation_off && !appearance_off); + assert_eq!(saved(&doc), before); + assert_eq!(doc.get(annotation).as_dict().unwrap().get(b"AP").unwrap().as_dict().unwrap().reference(b"N"), Some(appearance)); + } + } + let (mut doc, _, annotation, appearance) = layer_fixture(true); + let visible = add_group(&mut doc, "Visible", false); + let mut other = doc.get(annotation).as_dict().unwrap().clone(); + other.set(b"OC".to_vec(), Object::Ref(visible)); + other.set(b"Rect".to_vec(), numbers(&[10.0, 50.0, 50.0, 70.0])); + let other = doc.add(Object::Dict(other)); + let page = pdfcraft_model::pages(&doc)[0].obj; + doc.update_dict(page, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(annotation), Object::Ref(other)]))).unwrap(); + let before = saved(&doc); + let original = doc.get(appearance); + let first = print(&doc).unwrap(); + let reopened = Document::open(Arc::new(first.clone())).unwrap(); + for bytes in [first, print(&reopened).unwrap()] { + let image = render(bytes); + assert_appearance(&image, false); + assert_eq!(pixel(&image, 30, 40), [0, 255, 0, 255], "the other annotation uses the same appearance in a visible layer"); + } + let form = printed_form(&reopened); + let resources = reopened.resolve(form.get(b"Resources").unwrap()); + let objects = reopened.resolve(resources.as_dict().unwrap().get(b"XObject").unwrap()); + let objects = objects.as_dict().unwrap(); + assert_eq!(objects.get(b"PCPrA0"), objects.get(b"PCPrA1"), "membership belongs to each invocation, not an appearance copy"); + assert_eq!(doc.get(appearance), original); + assert_eq!(saved(&doc), before); +} + +#[test] +fn annotation_layers_keep_colliding_inherited_and_shared_page_properties() { + for indirect_resources in [false, true] { + for indirect_properties in [false, true] { + for inherited in [false, true] { + let (mut doc, hidden, annotation, appearance) = layer_fixture(true); + let visible = add_group(&mut doc, "Visible", false); + let unrelated = Object::Dict(Dict::from_iter([(b"PrivateKey".to_vec(), Object::name("KeepMe"))])); + let properties = Dict::from_iter([ + (b"PCPrOC0".to_vec(), Object::Ref(hidden)), + (b"PCPrOC1".to_vec(), Object::Ref(visible)), + (b"PCPrOC3".to_vec(), Object::Null), + (b"Unrelated".to_vec(), unrelated.clone()), + ]); + let properties = if indirect_properties { Object::Ref(doc.add(Object::Dict(properties))) } else { Object::Dict(properties) }; + let resources = Dict::from_iter([(b"Properties".to_vec(), properties)]); + let resources = if indirect_resources { Object::Ref(doc.add(Object::Dict(resources))) } else { Object::Dict(resources) }; + // The appearance shares the page's resource owner, so in-place extension would change both. + doc.update_dict(appearance, |d| d.set(b"Resources".to_vec(), resources.clone())).unwrap(); + let mut other = doc.get(annotation).as_dict().unwrap().clone(); + other.set(b"OC".to_vec(), Object::Ref(visible)); + other.set(b"Rect".to_vec(), numbers(&[10.0, 50.0, 50.0, 70.0])); + let other = doc.add(Object::Dict(other)); + let contents = doc.add(Object::Stream(Stream::flate( + Dict::new(), + b"0 0 1 rg 0 0 8 8 re f\n/OC /PCPrOC0 BDC 1 0 0 rg 60 60 10 10 re f EMC\n/OC /PCPrOC1 BDC 0 0 1 rg 70 60 10 10 re f EMC\n/Span /Unrelated BDC 1 0 1 rg 80 60 10 10 re f EMC", + ))); + let page = pdfcraft_model::pages(&doc)[0].obj; + doc.update_dict(page, |d| { + d.set(b"Contents".to_vec(), Object::Ref(contents)); + d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(annotation), Object::Ref(other)])); + }) + .unwrap(); + if inherited { + let parent = doc.get(page).as_dict().unwrap().reference(b"Parent").unwrap(); + doc.update_dict(parent, |d| d.set(b"Resources".to_vec(), resources)).unwrap(); + doc.update_dict(page, |d| { + d.remove(b"Resources"); + }) + .unwrap(); + } else { + doc.update_dict(page, |d| d.set(b"Resources".to_vec(), resources)).unwrap(); + } + let before = saved(&doc); + let original_appearance = doc.get(appearance); + let first = print(&doc).unwrap(); + let reopened = Document::open(Arc::new(first.clone())).unwrap(); + let properties = form_properties(&reopened); + assert_eq!(properties.get(b"PCPrOC3"), Some(&Object::Null)); + assert_eq!(properties.get(b"Unrelated"), Some(&unrelated)); + assert_eq!(properties.get(b"PCPrOC2"), properties.get(b"PCPrOC0")); + assert_eq!(properties.get(b"PCPrOC4"), properties.get(b"PCPrOC1")); + for bytes in [first, print(&reopened).unwrap()] { + let image = render(bytes); + assert_appearance(&image, false); + assert_eq!(pixel(&image, 30, 40), [0, 255, 0, 255]); + assert_eq!(pixel(&image, 65, 35), [255, 255, 255, 255], "existing hidden page content"); + assert_eq!(pixel(&image, 75, 35), [0, 0, 255, 255], "existing visible page content"); + assert_eq!(pixel(&image, 85, 35), [255, 0, 255, 255], "unrelated marked-content property"); + } + assert_eq!(doc.get(appearance), original_appearance); + assert_eq!(saved(&doc), before, "resources={indirect_resources}, properties={indirect_properties}, inherited={inherited}"); + } + } + } +} + +fn reference_chain(doc: &mut Document, mut value: Object, references: usize) -> Object { + for _ in 0..references { + value = Object::Ref(doc.add(value)); + } + value +} + +fn cycle(doc: &mut Document, two_nodes: bool) -> Object { + let first = doc.add(Object::Null); + let second = if two_nodes { doc.add(Object::Ref(first)) } else { first }; + doc.set(first, Object::Ref(second)); + Object::Ref(first) +} + +#[test] +fn absent_annotation_membership_and_null_resource_maps_remain_valid() { + for case in 0..6 { + let (mut doc, _, annotation, _) = layer_fixture(true); + match case { + 0 => { + doc.update_dict(annotation, |d| { + d.remove(b"OC"); + }) + .unwrap(); + } + 1 => doc.update_dict(annotation, |d| d.set(b"OC".to_vec(), Object::Null)).unwrap(), + 2 => doc.update_dict(annotation, |d| d.set(b"OC".to_vec(), Object::Ref(ObjRef::new(900_000, 0)))).unwrap(), + _ => { + let page = pdfcraft_model::pages(&doc)[0].obj; + let resources = match case { + 3 => Object::Null, + 4 => Object::Dict(Dict::from_iter([(b"Properties".to_vec(), Object::Null)])), + _ => Object::Dict(Dict::from_iter([(b"Properties".to_vec(), Object::Ref(ObjRef::new(900_000, 0)))])), + }; + doc.update_dict(page, |d| d.set(b"Resources".to_vec(), resources)).unwrap(); + } + } + let before = saved(&doc); + assert_appearance(&render(print(&doc).unwrap()), case < 3); + assert_eq!(saved(&doc), before, "null/absent case={case}"); + } +} + +#[test] +fn invalid_annotation_membership_is_refused_without_mutation() { + for case in 0..9 { + let (mut doc, _, annotation, _) = layer_fixture(false); + let value = match case { + 0 => Object::Int(7), + 1 => Object::name("Layer"), + 2 => Object::Array(vec![]), + 3 => Object::Dict(Dict::new()), + 4 => Object::Dict(Dict::from_iter([(b"Type".to_vec(), Object::name("Page"))])), + 5 => Object::Dict(Dict::from_iter([(b"Type".to_vec(), Object::name("OCG"))])), + 6 => Object::Ref(doc.add(Object::Stream(Stream::flate(membership(vec![], None, None), b"")))), + 7 => cycle(&mut doc, false), + _ => cycle(&mut doc, true), + }; + doc.update_dict(annotation, |d| d.set(b"OC".to_vec(), value)).unwrap(); + let before = saved(&doc); + assert!(matches!(print(&doc), Err(PrintError::Invalid(_))), "invalid membership case={case}"); + assert_eq!(saved(&doc), before, "invalid membership case={case}"); + } +} + +#[test] +fn optional_content_reference_resolution_is_bounded_and_keeps_terminal_group_identity() { + for references in [64, 65] { + for target in ["membership", "type", "properties", "resources"] { + let (mut doc, group, annotation, _) = layer_fixture(true); + match target { + "membership" => { + // The final OCG is already indirect and counts as one hop. + let value = reference_chain(&mut doc, Object::Ref(group), references - 1); + doc.update_dict(annotation, |d| d.set(b"OC".to_vec(), value)).unwrap(); + } + "type" => { + let value = reference_chain(&mut doc, Object::name("OCG"), references); + doc.update_dict(group, |d| d.set(b"Type".to_vec(), value)).unwrap(); + } + _ => { + let value = reference_chain(&mut doc, Object::Dict(Dict::new()), references); + let resources = if target == "properties" { Object::Dict(Dict::from_iter([(b"Properties".to_vec(), value)])) } else { value }; + let page = pdfcraft_model::pages(&doc)[0].obj; + doc.update_dict(page, |d| d.set(b"Resources".to_vec(), resources)).unwrap(); + } + } + let before = saved(&doc); + let result = print(&doc); + if references == 64 { + let bytes = result.unwrap(); + // Check Type aliases structurally: rendering them would also test the + // renderer's separate reference-resolution behavior. + if target == "type" { + let reopened = Document::open(Arc::new(bytes)).unwrap(); + let root = reopened.get(reopened.root().unwrap()); + let catalog = reopened.resolve(root.as_dict().unwrap().get(b"OCProperties").unwrap()); + let groups = catalog.as_dict().unwrap().get(b"OCGs").unwrap().as_array().unwrap(); + assert_eq!(form_properties(&reopened).get(b"PCPrOC0"), groups.first()); + } else { + assert_appearance(&render(bytes), false); + } + } else { + assert!(matches!(result, Err(PrintError::Invalid(_))), "target={target}"); + } + assert_eq!(saved(&doc), before, "target={target}, references={references}"); + } + } +} + +#[test] +fn malformed_page_resource_maps_cannot_remove_optional_content_guards() { + for target in ["Resources", "Properties"] { + for case in 0..4 { + let (mut doc, _, _, _) = layer_fixture(true); + let value = match case { + 0 => Object::Int(7), + 1 => Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), b"")))), + 2 => cycle(&mut doc, false), + _ => cycle(&mut doc, true), + }; + let resources = if target == "Properties" { Object::Dict(Dict::from_iter([(b"Properties".to_vec(), value)])) } else { value }; + let page = pdfcraft_model::pages(&doc)[0].obj; + doc.update_dict(page, |d| d.set(b"Resources".to_vec(), resources)).unwrap(); + let before = saved(&doc); + assert!(matches!(print(&doc), Err(PrintError::Invalid(_))), "target={target}, case={case}"); + assert_eq!(saved(&doc), before, "target={target}, case={case}"); + } + } +} + +#[test] +fn malformed_membership_on_annotations_that_do_not_draw_does_not_block_printing() { + for case in 0..6 { + let (mut doc, _, annotation, _) = layer_fixture(false); + doc.update_dict(annotation, |d| { + d.set(b"OC".to_vec(), Object::Int(7)); + match case { + 0 => d.set(b"F".to_vec(), Object::Int(0)), + 1 => d.set(b"F".to_vec(), Object::Int(6)), + // A Popup never prints. (A Link did not either, until #5028 printed existing + // Link appearances.) + 2 => d.set(b"Subtype".to_vec(), Object::name("Popup")), + 3 => { + d.remove(b"AP"); + } + 4 => d.set(b"Rect".to_vec(), Object::Array(vec![])), + _ => {} + } + }) + .unwrap(); + // Resource validation is also lazy: these maps are irrelevant when no guarded + // annotation invocation will be appended. + let page = pdfcraft_model::pages(&doc)[0].obj; + let resources = + if case % 2 == 0 { Object::Dict(Dict::from_iter([(b"Properties".to_vec(), Object::Int(7))])) } else { cycle(&mut doc, false) }; + doc.update_dict(page, |d| d.set(b"Resources".to_vec(), resources)).unwrap(); + let before = saved(&doc); + let bytes = if case == 5 { + impose( + &doc, + &Settings { + pages: vec![0], + paper: (100.0, 100.0), + layout: Layout::Size(SizeMode::Actual), + content: Content::Document, + ..Settings::default() + }, + ) + .unwrap() + } else { + print(&doc).unwrap() + }; + assert_appearance(&render(bytes), false); + assert_eq!(saved(&doc), before, "excluded annotation case={case}"); + } +} diff --git a/docs/roadmap.md b/docs/roadmap.md index 90770f1dac..4b6c4dc656 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,6 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: vector printing preserves annotation optional-content membership; estimates unchanged) > **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M10 #5029: Text appearances have implicit NoRotate during vector printing even when the flag is clear; estimates unchanged) > > **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: vector-print appearance transforms retain small coefficients; estimates unchanged) @@ -266,6 +267,7 @@ total matches the parity estimate. | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Changed marked-content spans lose replacement descriptions; untouched spans keep theirs. Hidden-text cleanup retires unused named descriptions across inherited/shared page and form resources (64 focused tests and scoped Rust 1.96 Clippy/wasm checks pass; publication CI pending). The redaction audit counts a filled cover that hides half of any one word of a long line (#2662), applied or not. Phone-pattern redaction also matches labelled Italian geographic numbers with `+39` and a slash after the area code (#3205 follow-up); unlabelled international slash numbers remain unsupported. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | | M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.
Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 | +| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Model staging fetches SHA-256-verified ONNX detection/recognition files after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, then complete legacy RTEN pairs, without mixing formats or changing directory precedence. Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. #3532 partial: vector printing retains existing appearances with absent Subtype through a print-owned Form copy; source artwork stays unchanged. #3120: Office export reads form XObjects 50 levels deep, as the renderer does, and up to 50 000 a page; pages past that are listed in `truncated_pages`. Word table cells use the grid columns they span, including cells after a merged cell. Done since: text in a table slot covered by another cell's span remains as paragraph text in Word, HTML, RTF and PowerPoint. Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import. Done since: dotted-leader contents entries stay in separate complete paragraphs (#1757) #2156 candidate: last selected print paper persists across sessions; other print preferences unchanged. Image export applies soft-mask Decode opacity intervals before resizing, including indirect entries and inverted ranges. Vector printing gives annotation appearances unoccupied XObject names so existing page artwork survives. Vector printing preserves each annotation's OCG/OCMD membership and shared-appearance independence (#5005). | | M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Model staging fetches SHA-256-verified ONNX detection/recognition files after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, then complete legacy RTEN pairs, without mixing formats or changing directory precedence. Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. #3532 partial: vector printing retains existing appearances with absent Subtype through a print-owned Form copy; source artwork stays unchanged. #3120: Office export reads form XObjects 50 levels deep, as the renderer does, and up to 50 000 a page; pages past that are listed in `truncated_pages`. Word table cells use the grid columns they span, including cells after a merged cell. Done since: text in a table slot covered by another cell's span remains as paragraph text in Word, HTML, RTF and PowerPoint. Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import. Done since: dotted-leader contents entries stay in separate complete paragraphs (#1757) #2156 candidate: last selected print paper persists across sessions; other print preferences unchanged. Image export applies soft-mask Decode opacity intervals before resizing, including indirect entries and inverted ranges. Vector printing gives annotation appearances unoccupied XObject names so existing page artwork survives. Appearance placement retains small transform coefficients for large bounding boxes and matrices. Existing printable NoRotate appearances retain orientation and the upper-left rectangle anchor through source page rotation, including implicit NoRotate on Text annotations with the flag clear; later sheet transforms still apply (#5029). | | M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Target-version compatibility is partial: newer content is not detected or converted. Content stream boundaries preserve comments, split operands and graphics state when image fragments merge. Image downsampling includes page-local `/UserUnit` scaling when measuring physical resolution. Done since: reject invalid image and soft-mask dimensions with checked sizing (#2178). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | | M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Changed marked-content spans lose replacement descriptions; untouched spans keep theirs. Hidden-text cleanup retires unused named descriptions across inherited/shared page and form resources (64 focused tests and scoped Rust 1.96 Clippy/wasm checks pass; publication CI pending). Retained text/image-bearing or unreadable tiling-pattern cells now make Apply refuse atomically after a bounded check of rewritten resources; fully removed cells still apply, and cross-page retention is checked. The redaction audit counts a filled cover that hides half of any one word of a long line (#2662), applied or not. Phone-pattern redaction also matches labelled Italian geographic numbers with `+39` and a slash after the area code (#3205 follow-up); unlabelled international slash numbers remain unsupported. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | @@ -323,6 +325,9 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st ## Revision history +**2026-10-27 · minor:** M10 #5005: vector printing preserves annotation optional-content membership. +Source resources and shared appearances remain unchanged. +Estimates unchanged. **2026-10-31 · patch:** M6/PR #4166 integrated main f137a289 (0.16.1), preserving both roadmap histories. Automatic Keystroke, Validate, Calculate and Format scripts still suppress Print alongside ShowPanel/SaveAs; explicit button and console Print requests, field effects and document-error provenance are retained. The three engine regressions and headless automation regression remain in their current modules. Manifests, lockfile and CI match incoming main. Windows x64 / Rust 1.95 validation: 761 engine/automation tests passed, zero failed and four existing ignored; formatting, scoped all-target Clippy with -D warnings, layers/assets/parity and the engine/automation WebAssembly check passed. A fresh CLI build and the 13-step synthetic form exercise passed; the inspected render shows Qty=4 and Total=8, and explicit button/console Print requests are retained. Main's 100 headless parity notices, five renderer WebAssembly warnings and three optional egui patch notices remain. Estimates unchanged. **2026-10-29 · minor:** M6/PR #4166 refreshed against upstream 0.15. Automatic field scripts still suppress Print with a console diagnostic alongside ShowPanel/SaveAs filtering, and explicit button/console scripts retain their requests and document-error provenance. The three engine regressions, headless regression and parity evidence remain in the current modules. Dependency manifests retain main's integrated craft-libs revision, including #366. Estimates unchanged. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 342d0bba8c..056ee06db1 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -8345,8 +8345,8 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print"] -evidence = ["crates/print/src/tests.rs::imposed_pdf_has_the_sheets_and_honours_comments_and_forms", "crates/print/src/tests/appearances.rs::annotation_appearances_do_not_replace_page_xobjects", "crates/print/src/tests/appearances.rs::printable_link_appearance_survives_vector_print", "crates/print/src/tests/appearances.rs::annotation_placement_preserves_small_transform_coefficients", "crates/print/src/tests/appearances.rs::indirect_annotation_rectangles_keep_their_printed_appearances", "crates/print/src/tests/appearances.rs::invalid_annotation_rectangles_are_skipped_when_printing", "crates/print/src/tests/appearances.rs::indirect_annotation_states_keep_their_printed_appearances", "crates/print/src/tests/appearances.rs::invalid_annotation_states_do_not_select_an_appearance", "crates/print/src/tests/appearances.rs::chained_annotation_values_keep_their_printed_appearances", "crates/print/src/tests/appearances.rs::invalid_annotation_reference_chains_are_skipped_when_printing", "crates/print/src/tests/appearances.rs::vector_print_preserves_no_rotate_appearance_anchors", "crates/print/src/tests/appearances.rs::no_rotate_keeps_user_units_and_sheet_scaling"] -notes = "Existing field and comment appearances retain direct or indirect rectangle coordinates and appearance-state names, including reference chains of up to 64 objects with cycle detection. State dictionaries require a usable AS name; missing/invalid/unresolved/cyclic/overly deep or unknown names select no appearance, while a single normal stream needs no AS. Print content modes and source objects are preserved. Malformed rectangles are skipped; missing appearances are not generated. Print and Hidden flags honoured; existing Link appearances print as document content, but missing Link appearances are not synthesized. Appearance resource names preserve existing page XObjects and distinct annotations, including inherited resources. Placement retains small transform coefficients for large appearance bounding boxes and matrices. Existing NoRotate appearances retain orientation and the rectangle upper-left anchor through source page rotation, including Text annotations with implicit NoRotate when the flag is clear; CropBox, UserUnit, sheet scaling and later imposition rotation still apply. NoZoom does not cancel print scale. Missing or unusable appearance streams remain outside this path." +evidence = ["crates/print/src/tests.rs::imposed_pdf_has_the_sheets_and_honours_comments_and_forms", "crates/print/src/tests/appearances.rs::annotation_appearances_do_not_replace_page_xobjects", "crates/print/src/tests/appearances.rs::printable_link_appearance_survives_vector_print", "crates/print/src/tests/appearances.rs::annotation_placement_preserves_small_transform_coefficients", "crates/print/src/tests/appearances.rs::indirect_annotation_rectangles_keep_their_printed_appearances", "crates/print/src/tests/appearances.rs::invalid_annotation_rectangles_are_skipped_when_printing", "crates/print/src/tests/appearances.rs::indirect_annotation_states_keep_their_printed_appearances", "crates/print/src/tests/appearances.rs::invalid_annotation_states_do_not_select_an_appearance", "crates/print/src/tests/appearances.rs::chained_annotation_values_keep_their_printed_appearances", "crates/print/src/tests/appearances.rs::invalid_annotation_reference_chains_are_skipped_when_printing", "crates/print/src/tests/appearances.rs::vector_print_preserves_no_rotate_appearance_anchors", "crates/print/src/tests/appearances.rs::no_rotate_keeps_user_units_and_sheet_scaling", "crates/print/src/tests/optional_content.rs::vector_print_preserves_annotation_layer_membership"] +notes = "Existing field and comment appearances retain direct or indirect rectangle coordinates and appearance-state names, including reference chains of up to 64 objects with cycle detection. State dictionaries require a usable AS name; missing/invalid/unresolved/cyclic/overly deep or unknown names select no appearance, while a single normal stream needs no AS. Print content modes and source objects are preserved. Malformed rectangles are skipped; missing appearances are not generated. Print and Hidden flags honoured; existing Link appearances print as document content, but missing Link appearances are not synthesized. Appearance resource names preserve existing page XObjects and distinct annotations, including inherited resources. Placement retains small transform coefficients for large appearance bounding boxes and matrices. Existing NoRotate appearances retain orientation and the rectangle upper-left anchor through source page rotation, including Text annotations with implicit NoRotate when the flag is clear; CropBox, UserUnit, sheet scaling and later imposition rotation still apply. NoZoom does not cancel print scale. Vector output preserves per-annotation OCG/OCMD membership without changing shared appearances, including existing layer overrides; nested policy data remains reader-evaluated, and the separate raster annotation-visibility gap remains. Missing or unusable appearance streams remain outside this path." [[feature]] id = "print.print-with-comment-summary"