diff --git a/Cargo.lock b/Cargo.lock index 1c64e1f20c..6bcd2d3d28 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=3c978dfcf3d460577fabd87aeea7a84ffdcfb396)", "winsafe", ] @@ -4665,6 +4665,7 @@ dependencies = [ "pdfcraft-render", "skrifa 0.44.0", "thiserror 2.0.21", + "winprint 0.2.1 (git+https://github.com/storytold/winprint.rs?rev=5f80b9baf6b58937c401656634e42d33f00ddf49)", ] [[package]] @@ -8335,6 +8336,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/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index 7695b4ccdd..3b40832b83 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -731,6 +731,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 1e1f6e5fe0..c2a330a095 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -46,45 +46,78 @@ 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 (or a size such as "8.5x14in", whose index is past the list); US Letter when -/// none is given. -fn paper_named(name: Option<&str>) -> Result<(usize, (f64, f64))> { - // A size of its own: "8.5x14in", "210x297mm", "10x15cm", "612x792pt". It has no place in - // `PAPERS` (index `PAPERS.len()`), so no `media` option is sent for it. - if let Some(size) = name.and_then(paper_size) { - return Ok((PAPERS.len(), size)); - } - let mut papers = PAPERS.iter().enumerate(); - let found = match name { - None => papers.next(), - Some(p) => papers.find(|(_, (n, _))| { - 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)) - }), +/// 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(); + let (w_str, h_str) = s.split_once(['x', 'X', '*'])?; + let parse_dim = |d: &str| { + let d = d.trim().trim_end_matches("points").trim_end_matches("pt").trim(); + d.parse::().ok() }; - found.map(|(index, (_, size))| (index, *size)).ok_or_else(|| { - bad(format!( - "unknown paper {:?}; choose one of {}, or a size such as 8.5x14in or 210x297mm (1 to 200 inches a side)", - name.unwrap_or_default(), + 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 } +} + +/// `doc_print`'s `paper`, by its name, short form, driver-detected paper name, or custom dimensions. +/// Returns `((width, height), media_name)`. +fn resolve_paper(name: Option<&str>, printer: Option<&str>) -> Result<((f64, f64), Option)> { + 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(|label| label.eq_ignore_ascii_case(p)) + { + let media = print::cups_media(index).map(String::from); + return Ok((*size, media)); + } + } + // 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((name, size)) = driver_papers.iter().find(|(n, _)| n.eq_ignore_ascii_case(p)) { + return Ok((*size, Some(print::paper_media(Some(name), *size)))); + } + } + // 3. Match custom dimensions ("x"). + if let Some(size) = parse_custom_dimensions(p) { + return Ok((size, Some(print::paper_media(None, size)))); + } + // 4. Explicit units request the same stock as point dimensions. + if let Some(size) = paper_size(p) { + 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)", 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((name, size)) = spool::printer_default_paper(printer_name) + { + return Ok((size, Some(print::paper_media(Some(&name), size)))); + } + + let (name, size) = PAPERS[0]; + let media = print::paper_media(Some(name), size); + Ok((size, Some(media))) } /// 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), and tells -/// CUPS which way the laid-out `sheets` run, as the Print dialog does (#2713): an orientation the -/// caller chose in `options` stands. It asks the driver for grayscale only when the pages aren't -/// grey pictures already ([`print::driver_needs_grayscale`]). +/// (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), and tells CUPS which way the +/// laid-out `sheets` run, as the Print dialog does (#2713): an orientation the caller chose in +/// `options` stands. It asks the driver for grayscale only when the pages aren't grey pictures +/// already ([`print::driver_needs_grayscale`]). fn spool_job( a: &Args, printer: &str, title: String, duplex: Option, - paper: usize, + media: Option<&str>, raster: Option, sheets: &[print::Sheet], ) -> Result { @@ -110,7 +143,7 @@ fn spool_job( grey_pages: false, title, options: { - let mut options: Vec<(String, String)> = options.into_iter().chain(print::media_option(paper)).collect(); + let mut options: Vec<(String, String)> = options.into_iter().chain(media.map(|m| ("media".to_string(), m.to_string()))).collect(); print::add_cups_orientation(&mut options, sheets); options }, @@ -185,6 +218,21 @@ impl Automation { Ok(json!({ "printer": printer, "count": list.len(), "options": list })) } + /// `printer_papers`: the paper sizes (names and dimensions in points) and default paper + /// reported by the printer driver. + pub(crate) fn printer_papers(&self, a: &Args) -> 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 { @@ -276,7 +324,18 @@ impl Automation { }; let content = a.opt_str("comments_forms")?.unwrap_or("document-and-markups"); let content = print::names::parse_content(content).ok_or_else(|| bad(format!("unknown comments_forms {content:?}")))?; - let (paper_index, paper) = paper_named(a.opt_str("paper")?)?; + // Use the same queue for duplex detection, paper discovery and submission, even if the + // system default changes while the document is rendered. + let resolved_printer = printer.map(|p| { + if let Some(asked) = &asked { + asked.name.clone() + } else if p == "default" { + spool::printer_queues().into_iter().find(|pr| pr.default).map(|pr| pr.name).unwrap_or_else(|| p.to_string()) + } else { + p.to_string() + } + }); + let (paper, media) = 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) { @@ -311,8 +370,8 @@ impl Automation { out["path"] = json!(path.to_string_lossy()); } (None, Some(printer)) => { - let printer = asked.as_ref().map_or(printer, |p| p.name.as_str()); - let mut job = spool_job(a, printer, self.doc(a)?.name.clone(), duplex, paper_index, settings.raster, &laid_out)?; + let actual_printer = resolved_printer.as_deref().unwrap_or(printer); + let mut job = spool_job(a, actual_printer, self.doc(a)?.name.clone(), duplex, media.as_deref(), settings.raster, &laid_out)?; let bytes = pdfcraft_engine::spooler::spooler_copy(bytes, &mut job); out["job"] = json!(spool::submit(&bytes, &job).map_err(|e| failed(e.to_string()))?); } @@ -397,7 +456,7 @@ mod tests { #[test] fn printing_landscape_sheets_to_a_printer_asks_cups_to_turn_them() { let sheets = |sizes: &[(f64, f64)]| sizes.iter().map(|&size| print::Sheet { size, ..Default::default() }).collect::>(); - let job = |args: Value, s: &[print::Sheet]| spool_job(&Args(&args), "Office", "a.pdf".into(), None, 0, None, s).unwrap().options; + let job = |args: Value, s: &[print::Sheet]| spool_job(&Args(&args), "Office", "a.pdf".into(), None, Some("Letter"), None, s).unwrap().options; let media = ("media".to_string(), "Letter".to_string()); let landscape = sheets(&[(792.0, 612.0), (792.0, 612.0)]); let turned = if cfg!(windows) { vec![media.clone()] } else { vec![media.clone(), ("orientation-requested".to_string(), "4".to_string())] }; @@ -413,8 +472,8 @@ 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(); - spool_job(&Args(&args), "Office", "a.pdf".into(), None, index, None, &[]).unwrap() + let (_, media) = resolve_paper(paper_name, None).unwrap(); + spool_job(&Args(&args), "Office", "a.pdf".into(), None, media.as_deref(), None, &[]).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. @@ -423,18 +482,36 @@ 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((*size, Some(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")]); - assert!(matches!(paper_named(Some("Quarto")), 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!(paper_named(Some("8.5x14in")).ok(), Some((PAPERS.len(), (612.0, 1008.0)))); - assert!(job(json!({}), Some("8.5x14in")).options.is_empty()); - assert_eq!(spool_job(&Args(&json!({})), "default", String::new(), None, 0, None, &[]).ok().map(|j| j.printer), Some(None)); - assert!(spool_job(&Args(&json!({ "options": { "InputSlot": 3 } })), "Office", String::new(), None, 0, None, &[]).is_err()); + // 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"))); + // 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()); } /// #5806: grayscale with Print as image sends grey pictures, so the driver isn't asked for @@ -443,8 +520,9 @@ mod tests { fn grey_pictures_dont_ask_the_driver_for_grayscale() { let grey = print::Raster { dpi: 300.0, grayscale: true }; let colour = print::Raster { dpi: 300.0, grayscale: false }; - let job = - |args: Value, raster: Option| spool_job(&Args(&args), "Office", String::new(), None, 0, raster, &[]).unwrap().grayscale; + let job = |args: Value, raster: Option| { + spool_job(&Args(&args), "Office", String::new(), None, Some("Letter"), raster, &[]).unwrap().grayscale + }; assert!(job(json!({ "grayscale": true }), None), "vector pages: the driver makes them grey"); assert!(!job(json!({ "grayscale": true }), Some(grey)), "grey pictures: nothing for the driver to do"); assert!(job(json!({ "grayscale": true }), Some(colour)), "colour pictures still need the driver"); diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 1cd876d0b1..56c654fa1c 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -754,10 +754,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, \"(n)\" for the nth page whatever its label, unless a page is labelled exactly \"(n)\", since labels are looked up first; 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, which turns the sheet to suit the grid, or a custom grid of cols across by rows down, each 1-99, on the sheet as oriented (auto prints portrait); 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, \"(n)\" for the nth page whatever its label, unless a page is labelled exactly \"(n)\", since labels are looked up first; 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, which turns the sheet to suit the grid, or a custom grid of cols across by rows down, each 1-99, on the sheet as oriented (auto prints portrait); 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 f6c58cc348..632f692e9f 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -6292,6 +6292,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 7a0506ca8b..9d349b6568 100644 --- a/crates/print/Cargo.toml +++ b/crates/print/Cargo.toml @@ -16,6 +16,9 @@ pdfcraft-content = { workspace = true } 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", default-features = false } + [dev-dependencies] pdfcraft-render = { workspace = true } # Reads the renamed programs back with a parser that isn't ours. diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index 885a5953b3..5707a42012 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -39,13 +39,14 @@ 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, MAX_GRID, Orientation, PAPERS, PageOrder, Raster, Settings, SizeMode, booklet_duplex, - booklet_job_duplex, cups_media, driver_needs_grayscale, media_option, + booklet_job_duplex, cups_media, custom_media, driver_needs_grayscale, media_option, paper_media, paper_media_option, }; #[cfg(test)] use sheets::text_direction; pub use sheets::{ LABEL_MARGIN, MARGIN, MAX_CREEP, Placement, Sheet, add_cups_orientation, cups_orientation, layout, matching_paper, text_reads_up, texts_read_up, }; +pub use spool::{open_printer_preferences, printer_default_paper, printer_paper_info, printer_papers, show_printer_properties}; /// The names of the Print dialog's choices, shared by the `doc_print` tool and the settings /// file, so a saved preference reads like a tool argument and there is one vocabulary to keep. diff --git a/crates/print/src/settings.rs b/crates/print/src/settings.rs index 619f775926..50657b328a 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 { @@ -301,3 +302,51 @@ 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)| { + 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 + && !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/spool.rs b/crates/print/src/spool.rs index ddeba8354a..f80b349916 100644 --- a/crates/print/src/spool.rs +++ b/crates/print/src/spool.rs @@ -53,6 +53,19 @@ fn unavailable(dll: &str) -> crate::PrintError { crate::PrintError::Spool(format!("printing needs {dll}, which this Windows installation lacks; use Save as PDF instead")) } +/// Keeps two threads of this process out of the Windows PrintTicket provider (`PTOpenProvider` +/// and the calls on its handle) at the same time. When two threads make the process's first calls +/// together, winspool can keep a pointer to a device capabilities cache it has freed, and a later +/// call crashes the process. All entry points in this process that query driver capabilities +/// or submit jobs share this process-wide lock. +#[cfg(windows)] +static PRINT_TICKET_PROVIDER: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +#[cfg(windows)] +pub(crate) fn print_ticket_provider() -> std::sync::MutexGuard<'static, ()> { + PRINT_TICKET_PROVIDER.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} + /// The system's printers; none on a Windows installation without the print DLLs. pub fn printers() -> Vec { if missing_print_dll().is_some() { @@ -60,6 +73,8 @@ pub fn printers() -> Vec { } #[cfg(all(windows, test))] PRINTER_BACKEND_CALLS.with(|c| c.set(c.get().saturating_add(1))); + #[cfg(windows)] + let _provider = print_ticket_provider(); craft_print::printers() } @@ -83,6 +98,8 @@ pub fn two_sided(printer: &str) -> Option { } #[cfg(all(windows, test))] PRINTER_BACKEND_CALLS.with(|c| c.set(c.get().saturating_add(1))); + #[cfg(windows)] + let _provider = print_ticket_provider(); craft_print::two_sided(printer) } @@ -386,11 +403,14 @@ 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 { if let Some(dll) = missing_print_dll() { return Err(unavailable(dll)); } #[cfg(windows)] + let _provider = print_ticket_provider(); + #[cfg(windows)] let resolved = windows_duplex::job(job); #[cfg(windows)] let job = resolved.as_ref(); @@ -405,6 +425,8 @@ pub fn submit_with_progress(pdf: &[u8], job: &Job, progress: &mut dyn FnMut(usiz return Err(unavailable(dll)); } #[cfg(windows)] + let _provider = print_ticket_provider(); + #[cfg(windows)] let resolved = windows_duplex::job(job); #[cfg(windows)] let job = resolved.as_ref(); @@ -426,10 +448,134 @@ pub(crate) fn spooled_pdf(pdf: &[u8], rename: bool) -> std::borrow::Cow<'_, [u8] } } +/// 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())) } +/// 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 { + // 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() }; + 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.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") } + }); + 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 +} + +/// 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; + 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()) + .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)) +} + +/// 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) { + if missing_print_dll().is_some() { + return (Vec::new(), None); + } + #[cfg(windows)] + let _provider = print_ticket_provider(); + #[cfg(windows)] + let papers = printer_papers_internal(printer); + #[cfg(not(windows))] + 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 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> { + if let Some(dll) = missing_print_dll() { + return Err(unavailable(dll)); + } + #[cfg(windows)] + { + let devmode = match craft_print::rgb::settings_dialog(printer, None, parent) { + 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 papers = printer_papers(printer); + Ok(Some(resolve_paper_name(&papers, page.paper))) + } + #[cfg(not(windows))] + { + let _ = (printer, parent); + 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 29d22d8eff..1d5ce2ca26 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -1159,6 +1159,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())); +} + /// #4777: Folio (8.5 × 13 in) and F4 (210 × 330 mm) presets are offered, fed by their /// PWG 5101.1 media names (`na_foolscap_8.5x13in` and `om_folio_210x330mm`) and laid out at their /// proper dimensions. @@ -1681,6 +1703,97 @@ 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 + 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); + } + } + } + } +} + +/// Overlapping driver discovery, queue queries, and paper detection run concurrently across +/// worker threads without deadlocking or racing the PrintTicket provider. +#[test] +fn overlapping_paper_discovery_two_sided_and_queue_queries() { + let queues = crate::spool::printer_queues(); + let name = queues.first().map(|p| p.name.clone()).unwrap_or_else(|| "Microsoft Print to PDF".to_string()); + + let threads: Vec<_> = (0..8) + .map(|i| { + let printer = name.clone(); + std::thread::spawn(move || { + for _ in 0..5 { + match i % 5 { + 0 => { + let _ = crate::spool::printer_papers(&printer); + } + 1 => { + let _ = crate::spool::printer_default_paper(&printer); + } + 2 => { + let _ = crate::spool::two_sided(&printer); + } + 3 => { + let _ = crate::spool::printers(); + } + _ => { + let _ = crate::spool::printer_paper_info(&printer); + } + } + } + }) + }) + .collect(); + + for handle in threads { + handle.join().unwrap(); + } +} + #[test] fn a_page_form_without_annotations_shares_the_pages_indirect_resources() { let mut doc = fixture(2); diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 240ea16a8e..759682fade 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -132,6 +132,8 @@ impl PdfCraftApp { updates: updates::Updates::default(), printer_source: None, options_source: None, + default_paper_source: None, + printer_papers_source: None, spooler: None, two_sided_source: None, print_tasks: print_ui::PrintTasks::default(), diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index a5db36c6ad..8c96de4529 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -107,6 +107,16 @@ impl PdfCraftApp { /// later as a pending open, under the returned token. #[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( @@ -124,7 +134,11 @@ impl PdfCraftApp { // A new document opens with the remembered Highlight fields, which its scripts read; the // session keeps the document in front's. let context = self.session.open_context().with_highlight(self.view_defaults.highlight_fields); - let receiver = worker.submit(token, context, request.name.clone(), request.path.clone(), request.password, request.input)?; + let receiver = if priority { + worker.submit_priority(token, context, request.name.clone(), request.path.clone(), request.password, request.input)? + } else { + worker.submit(token, context, request.name.clone(), request.path.clone(), request.password, request.input)? + }; self.next_open_token = token; self.pending_opens.push(PendingOpen { token, @@ -660,7 +674,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/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index eea8ec5ad4..9546857d41 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -37,6 +37,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() { @@ -191,6 +192,9 @@ impl PdfCraftApp { // a custom paper is stored by the name it was given. let d = &self.print_draft; v["print_paper"] = serde_json::json!((d.paper < d.paper_count()).then(|| d.paper_name())); + 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); // Also set outside json! above, which is at serde_json's macro recursion limit. v["print"] = print; // Each printer's paper, sizing and two-sided choice, the one printed to last first (#2001). @@ -312,8 +316,31 @@ impl PdfCraftApp { self.print_draft.custom_papers = print_paper::read_custom(&v["print_custom_papers"]); } // After the custom papers, so a custom paper chosen last time is found by its name. - if let Some(paper) = v.get("print_paper").and_then(serde_json::Value::as_str).and_then(|name| self.print_draft.paper_index(name)) { - self.print_draft.paper = paper; + if let Some(name) = v.get("print_paper").and_then(serde_json::Value::as_str) { + 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) + }) + }) + .or_else(|| self.print_draft.paper_index(name)); + 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(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(false); + } } if let Some(setups) = crate::print_ui::setups_from_json(&v["print_printers"]) { self.print_draft.setups = setups; @@ -366,6 +393,7 @@ impl PdfCraftApp { if let Some(on) = v["always_default_view"].as_bool() { self.always_default_view = on; } + self.restore_print_prefs(v); if let Some(on) = v["highlight_fields"].as_bool() { self.view_defaults.highlight_fields = on; } @@ -375,7 +403,6 @@ impl PdfCraftApp { if let Some(closed) = v["right_panel_closed"].as_bool() { self.right_panel_closed = closed; } - self.restore_print_prefs(v); // Scan & OCR choices (language, engine, strategy, output mode, preprocessing and skip // flags); unknown or malformed values keep the defaults. self.ocr_draft.apply_pref(&v["ocr"]); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 097eeb736c..7dfa39aa8c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -60,8 +60,8 @@ mod hotbar; mod panels; pub mod panes; pub use print_ui::{ - Handling as PrintHandling, OptionsSource, PrintDraft, PrinterSetup, PrinterSource, Spooler, TwoSidedSource, Which as PrintWhich, system_printers, - system_two_sided, + DefaultPaperSource, Handling as PrintHandling, OptionsSource, PrintDraft, PrinterPapersSource, PrinterSetup, PrinterSource, Spooler, + TwoSidedSource, Which as PrintWhich, system_printers, system_two_sided, }; pub use redact_ui::{HiddenDraft, PagesDraft as RedactPagesDraft, RedactPrefs, SearchDraft as RedactSearchDraft, SearchMode as RedactSearchMode}; pub mod i18n; @@ -435,6 +435,10 @@ 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, + /// Where a printer's supported paper sizes come from: driver capabilities unless a test stands in. + pub printer_papers_source: Option, /// What print jobs go to: the system spooler unless a test stands in ([`Spooler`]). pub spooler: Option, /// Reads the picked printer's two-sided support in the background (the desktop app sets it; diff --git a/crates/ui-egui/src/platform/open_worker.rs b/crates/ui-egui/src/platform/open_worker.rs index 94052f6368..8f7b0c5d3f 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 55bd4cb299..62520f3edf 100644 --- a/crates/ui-egui/src/platform/open_worker/tests.rs +++ b/crates/ui-egui/src/platform/open_worker/tests.rs @@ -922,6 +922,55 @@ fn a_repeated_waiting_open_keeps_the_tool_it_brings() { 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(zeroize::Zeroizing::new("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); +} + /// A password prompt keeps a place in the worker's bounded queue for its retry: files named after /// the protected one fill the rest and wait in order, and the retry is queued, not refused as /// "too many documents are waiting to open" while the worker has yet to take the others. diff --git a/crates/ui-egui/src/workflows/print_paper.rs b/crates/ui-egui/src/workflows/print_paper.rs index bdfaac7fe7..b84d7bd78b 100644 --- a/crates/ui-egui/src/workflows/print_paper.rs +++ b/crates/ui-egui/src/workflows/print_paper.rs @@ -63,13 +63,13 @@ pub fn is_paper_name(name: &str) -> bool { impl PrintDraft { /// The paper list: the standard sizes, then the custom ones. pub fn paper_count(&self) -> usize { - PAPERS.len() + self.custom_papers.len() + self.papers.len() + self.custom_papers.len() } /// The chosen paper's size (points, portrait). pub fn paper_size(&self) -> (f64, f64) { - match self.paper.checked_sub(PAPERS.len()) { - None => PAPERS[self.paper.min(PAPERS.len() - 1)].1, + match self.paper.checked_sub(self.papers.len()) { + None => self.papers.get(self.paper).map_or(PAPERS[0].1, |p| p.1), Some(c) => self.custom_papers.get(c).map_or(PAPERS[0].1, |p| (p.width, p.height)), } } @@ -81,20 +81,23 @@ impl PrintDraft { /// The name of the paper at `index` in the list; the first size's for a stale index. pub fn paper_name_at(&self, index: usize) -> String { - match index.checked_sub(PAPERS.len()) { - None => PAPERS[index.min(PAPERS.len() - 1)].0.to_string(), + match index.checked_sub(self.papers.len()) { + None => self.papers.get(index).map_or_else(|| PAPERS[0].0.to_string(), |p| p.0.clone()), Some(c) => self.custom_papers.get(c).map_or_else(|| PAPERS[0].0.to_string(), |p| p.name.clone()), } } /// The index of the paper called `name`, standard or custom. pub fn paper_index(&self, name: &str) -> Option { - PAPERS.iter().position(|(n, _)| *n == name).or_else(|| self.custom_papers.iter().position(|p| p.name == name).map(|i| PAPERS.len() + i)) + self.papers + .iter() + .position(|(n, _)| *n == name) + .or_else(|| self.custom_papers.iter().position(|p| p.name == name).map(|i| self.papers.len() + i)) } /// True when the chosen paper is a custom one (which can be removed). pub fn paper_is_custom(&self) -> bool { - self.paper >= PAPERS.len() + self.paper >= self.papers.len() } /// Add (or replace, by name) a custom paper and choose it. @@ -104,12 +107,14 @@ impl PrintDraft { self.custom_papers.remove(0); } self.custom_papers.push(paper); - self.paper = PAPERS.len() + self.custom_papers.len() - 1; + // 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; } /// Take the chosen custom paper off the list; the dialog goes back to the first size. pub fn remove_custom_paper(&mut self) { - if let Some(i) = self.paper.checked_sub(PAPERS.len()) + if let Some(i) = self.paper.checked_sub(self.papers.len()) && i < self.custom_papers.len() { self.custom_papers.remove(i); diff --git a/crates/ui-egui/src/workflows/print_ui.rs b/crates/ui-egui/src/workflows/print_ui.rs index 452fa10daa..c53feedf75 100644 --- a/crates/ui-egui/src/workflows/print_ui.rs +++ b/crates/ui-egui/src/workflows/print_ui.rs @@ -6,7 +6,7 @@ #[cfg(not(target_arch = "wasm32"))] use crate::file_filter::AddFilterAnyCase as _; use egui::{Color32, Pos2, Rect, Stroke, pos2, vec2}; -use pdfcraft_engine::print::{self, Binding, BookletSubset, Content, Layout, Orientation, PAPERS, PageOrder, SizeMode, Subset, spool}; +use pdfcraft_engine::print::{self, Binding, BookletSubset, Content, Layout, Orientation, PageOrder, SizeMode, Subset, spool}; use crate::theme::{self, Tokens}; use crate::{PdfCraftApp, widgets}; @@ -43,6 +43,14 @@ 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>; + +/// 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>; + /// What takes a print job in place of the system spooler: a test's stand-in, which gets the /// print-ready PDF and the job and answers as the spooler would. Called off the GUI thread. pub type Spooler = std::sync::Arc Result + Send + Sync>; @@ -64,6 +72,11 @@ pub fn system_two_sided() -> TwoSidedSource { std::sync::Arc::new(spool::two_sided) } +#[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)] @@ -81,6 +94,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, @@ -162,6 +181,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() { @@ -222,7 +242,7 @@ impl PdfCraftApp { printers_loading, printer_picked: picked, units: self.units, - printer, + printer: printer.clone(), current_page: current, sheet: 0, selected, @@ -234,6 +254,10 @@ impl PdfCraftApp { if changed { self.print_draft.apply_setup(); } + #[cfg(not(target_arch = "wasm32"))] + if let Some(p) = printer { + self.read_papers_in_background(p); + } self.dialog = Some(crate::Dialog::Print); } @@ -281,6 +305,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. @@ -337,12 +362,102 @@ 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() { + 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); + } + } + } + self.print_draft.papers_loading = Some(printer.clone()); + if self.print_tasks.papers.iter().any(|(p, _)| *p == printer) { + return; + } + 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) { + 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()) { + if !papers.is_empty() { + d.apply_printer_papers(papers); + } + if !d.paper_overridden && default_paper.is_some() { + 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); } @@ -373,16 +488,27 @@ 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; let printer = default_printer(d, &printers); - if printer != d.printer { + let changed = printer != d.printer; + if changed { d.printer = printer; d.apply_setup(); } - } + changed + } else { + false + }; d.printers = printers; d.printers_loading = false; + if changed { + d.reset_unoverridden_custom_paper(); + d.apply_printer_papers(Vec::new()); + if let Some(p) = d.printer.clone() { + self.read_papers_in_background(p); + } + } } } self.poll_two_sided(); @@ -486,6 +612,7 @@ impl PdfCraftApp { self.print_tasks.two_sided.push((name, rx)); } + /// 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)])), @@ -498,6 +625,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 d4c9b3444d..d9a0f7280f 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::{CUSTOM_SCALE, 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, }; /// The narrowest the title is still worth cutting: an ellipsis and a couple of pixels at the title's @@ -201,6 +201,11 @@ pub(crate) fn body( d.driver_error = None; // What this printer was last printed with (#2001). d.apply_setup(); + d.reset_unoverridden_custom_paper(); + d.apply_printer_papers(Vec::new()); + if let Some(p) = d.printer.clone() { + d.read_papers_for = Some(p); + } } ui.end_row(); // The spooler answered with no printer at all (#160): say so, rather than @@ -247,23 +252,35 @@ pub(crate) fn body( let paper_label = ui.label(tl!("Paper:")); d.paper = d.paper.min(d.paper_count().saturating_sub(1)); let before = d.paper; + let mut paper_clicked = false; + let detecting = d.papers_loading(); ui.horizontal(|ui| { // A standard size's name is translated; a custom paper keeps the name it was given. - let shown = if d.paper_is_custom() { d.paper_name() } else { tl!(d.paper_name().as_str()).to_string() }; - craft_ui::ComboBox::from_id_salt("paper").selected_text(shown).width(260.0).show_ui(ui, |ui| { - for (i, (name, _)) in PAPERS.iter().enumerate() { - ui.selectable_value(&mut d.paper, i, tl!(name)); + let shown = if d.paper_is_custom() { d.paper_name().to_string() } else { tl!(&d.paper_name()).to_string() }; + craft_ui::ComboBox::from_id_salt("paper").selected_text(shown).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; + } } if !d.custom_papers.is_empty() { ui.separator(); } for (i, p) in d.custom_papers.iter().enumerate() { let label = format!("{} ({})", p.name, d.units.size(p.width, p.height)); - ui.selectable_value(&mut d.paper, PAPERS.len() + i, label); + if ui.selectable_value(&mut d.paper, d.papers.len() + i, label).clicked() { + paper_clicked = true; + } } }) .response .labelled_by(paper_label.id); + if detecting { + ui.label(egui::RichText::new(tl!("Detecting…")).small().color(t.text_muted)); + } if ui.button(tl!("Custom…")).on_hover_text(tl!("Add a paper size of your own to the list")).clicked() { d.custom_new = match d.custom_new { Some(_) => None, @@ -272,10 +289,38 @@ pub(crate) fn body( } if d.paper_is_custom() && ui.button(tl!("Remove")).on_hover_text(tl!("Take this paper size off the list")).clicked() { d.remove_custom_paper(); + paper_clicked = true; + } + let page_setup_tip = if spool::HAS_PRINTER_PREFERENCES { + 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.") + }; + 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() + { + d.driver_error = None; + #[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); + } + Ok(None) => {} + Err(e) => d.driver_error = Some(e.to_string()), + } } }); + if paper_clicked || 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.which != Which::Area { + if paper_clicked && d.paper != before && d.paper == print::LABEL_PAPER && d.which != Which::Area { d.paper_before_label = Some(before); d.handling = Handling::Label; d.sheet = 0; @@ -308,11 +353,12 @@ 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 { 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; @@ -649,8 +695,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 e74cd4e069..ced55e7a25 100644 --- a/crates/ui-egui/src/workflows/print_ui/draft.rs +++ b/crates/ui-egui/src/workflows/print_ui/draft.rs @@ -1,5 +1,6 @@ use super::setups::{self, PrinterSetup}; use super::{Binding, BookletSubset, Content, Layout, Orientation, PageOrder, SizeMode, Subset, print, spool}; +use pdfcraft_engine::print::PAPERS; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Which { @@ -83,6 +84,10 @@ pub struct PrintDraft { pub custom_new: Option<(String, String, String)>, /// 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). pub sheet: usize, pub current_page: usize, @@ -105,6 +110,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, /// Whether each printer's driver takes two-sided settings, as read in the background for /// the printer picked (see [`TwoSidedSource`](super::TwoSidedSource)); `None` when it couldn't be read. pub two_sided: std::collections::BTreeMap>, @@ -155,6 +164,8 @@ impl Default for PrintDraft { custom_papers: Vec::new(), custom_new: None, paper_before_label: None, + paper_overridden: false, + papers: default_papers(), sheet: 0, current_page: 0, driver_options: None, @@ -166,13 +177,202 @@ impl Default for PrintDraft { crop_pages: None, driver_options_loading: None, read_options_for: None, + papers_loading: None, + read_papers_for: None, two_sided: Default::default(), setups: Vec::new(), } } } +/// Standard fallback paper presets. +pub fn default_papers() -> Vec<(String, (f64, f64))> { + PAPERS.iter().map(|(n, s)| (n.to_string(), *s)).collect() +} + impl PrintDraft { + /// 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)| { + ((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) + }) + }) + } + + /// 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, + /// 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); + } + + /// 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 custom_paper_idx = self.paper.checked_sub(self.papers.len()); + let before_label_custom = self.paper_before_label.and_then(|idx| idx.checked_sub(self.papers.len())); + let before_label = if before_label_custom.is_some() { None } else { self.paper_before_label.and_then(|idx| self.papers.get(idx).cloned()) }; + let current_name = self.paper_name().to_string(); + let current_size = self.paper_size(); + let mut papers = default_papers(); + 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)); + } + } + self.papers = papers; + if let Some(c_idx) = custom_paper_idx { + if c_idx < self.custom_papers.len() { + self.paper = self.papers.len() + c_idx; + } else { + self.paper = 0; + } + } else 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; + } else { + self.papers.push((current_name, current_size)); + self.paper = self.papers.len() - 1; + } + if let Some(c_idx) = before_label_custom { + if c_idx < self.custom_papers.len() { + self.paper_before_label = Some(self.papers.len() + c_idx); + } else { + self.paper_before_label = None; + } + } else { + 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). + pub fn auto_select_default_paper(&mut self) { + 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 let Some(ref printer) = self.printer { + let detected = if let Some(source) = source { source(printer) } else { spool::printer_default_paper(printer) }; + 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; + } + } + } + + /// 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 None; + } + 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)); + 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 + } + }) { + 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)); + Some(self.papers.len() - 1) + } + } + + /// 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; + } + } + } + /// 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) { @@ -200,7 +400,8 @@ impl PrintDraft { // Label's stock is that job's; the printer keeps the paper Label replaced. let paper = if self.handling == Handling::Label { self.paper_before_label.unwrap_or(self.paper) } else { self.paper }; // By name, so a custom paper is found again however the list changes. - let setup = PrinterSetup { paper: self.paper_name_at(paper), size: self.size, custom_scale: self.custom_scale, duplex: self.duplex }; + let setup = + PrinterSetup { paper: self.paper_name_at(paper).to_string(), size: self.size, custom_scale: self.custom_scale, duplex: self.duplex }; setups::record(&mut self.setups, &printer, setup); } @@ -308,6 +509,7 @@ impl PrintDraft { self.as_image.then_some(print::Raster { dpi: self.dpi, grayscale: self.grayscale }) } + /// 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 } @@ -362,7 +564,8 @@ impl PrintDraft { } .into_iter() // The sheet the PDF was laid out on, not the queue's default paper. - .chain(print::media_option(self.paper)) + // 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/src/workflows/print_ui/setups.rs b/crates/ui-egui/src/workflows/print_ui/setups.rs index 96a2cad692..74e2118be3 100644 --- a/crates/ui-egui/src/workflows/print_ui/setups.rs +++ b/crates/ui-egui/src/workflows/print_ui/setups.rs @@ -84,7 +84,7 @@ mod tests { use super::*; fn setup(paper: usize, size: SizeMode, duplex: Option) -> PrinterSetup { - PrinterSetup { paper: super::super::PAPERS[paper].0.to_string(), size, custom_scale: 62.5, duplex } + PrinterSetup { paper: pdfcraft_engine::print::PAPERS[paper].0.to_string(), size, custom_scale: 62.5, duplex } } #[test] diff --git a/crates/ui-egui/tests/it/print.rs b/crates/ui-egui/tests/it/print.rs index 89f9885477..ca8c8b5592 100644 --- a/crates/ui-egui/tests/it/print.rs +++ b/crates/ui-egui/tests/it/print.rs @@ -729,19 +729,25 @@ fn leaving_label_gives_back_the_paper_it_replaced() { let a4 = paper("A4"); let mut h = harness(); 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()) && std::time::Instant::now() < deadline { + h.run_steps(1); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + h.run_steps(2); for (mode, tab) in [(PrintHandling::Size, "Size"), (PrintHandling::Poster, "Poster"), (PrintHandling::Multiple, "Multiple"), (PrintHandling::Booklet, "Booklet")] { h.state_mut().print_draft.paper = a4; + h.state_mut().print_draft.paper_overridden = true; h.state_mut().print_draft.handling = PrintHandling::Size; h.run_steps(2); h.get_by_role_and_label(Role::Button, "Label").click(); - h.run_steps(2); + h.run_steps(3); assert_eq!(h.state().print_draft.handling, PrintHandling::Label); assert_eq!(h.state().print_draft.paper, LABEL_PAPER, "Label puts label stock in"); h.get_by_role_and_label(Role::Button, tab).click(); - h.run_steps(2); + h.run_steps(3); assert_eq!(h.state().print_draft.handling, mode); assert_eq!(h.state().print_draft.paper, a4, "{tab} gives A4 back after Label"); h.get_by_value("A4"); @@ -808,21 +814,27 @@ fn a_paper_picked_from_the_menu_while_in_label_is_kept() { let (a4, letter) = (paper("A4"), paper("US Letter")); let mut h = harness(); 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()) && std::time::Instant::now() < deadline { + h.run_steps(1); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + h.run_steps(2); h.state_mut().print_draft.paper = a4; + h.state_mut().print_draft.paper_overridden = true; h.run_steps(2); h.get_by_role_and_label(Role::Button, "Label").click(); - h.run_steps(2); + h.run_steps(3); assert_eq!(h.state().print_draft.paper, LABEL_PAPER); h.get_by_value(PAPERS[LABEL_PAPER].0).click(); h.run_steps(2); h.get_by_label("US Letter").scroll_to_me(); h.run_steps(4); h.get_by_label("US Letter").click(); - h.run_steps(2); + h.run_steps(3); assert_eq!(h.state().print_draft.paper, letter); h.get_by_role_and_label(Role::Button, "Size").click(); - h.run_steps(2); + h.run_steps(3); assert_eq!(h.state().print_draft.handling, PrintHandling::Size); assert_eq!(h.state().print_draft.paper, letter, "the paper picked in Label is kept"); h.get_by_value("US Letter"); @@ -896,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(); @@ -916,10 +932,12 @@ 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)); } + 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); @@ -1010,12 +1028,42 @@ 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); // Grayscale is one of the preferences remembered across restarts (#2001); copies, pages and // orientation are this job's and are not saved. - let expected = pdfcraft_ui_egui::PrintDraft { paper: a4, grayscale: true, ..Default::default() }; + let expected = pdfcraft_ui_egui::PrintDraft { paper: a4, grayscale: true, paper_overridden: true, ..Default::default() }; assert_eq!(app.print_draft, expected, "only the paper and the remembered preferences persist"); + + // 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); + + // 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().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")); + 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)); assert!(restarted.state_mut().execute("print.dialog")); restarted.run_steps(3); @@ -1043,6 +1091,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 [ @@ -1074,11 +1123,382 @@ 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().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")); + 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); +} + +#[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 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] +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 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(|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"); + assert!(!h.state().print_draft.paper_overridden); + + // 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] +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 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}; 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"); @@ -1114,15 +1534,78 @@ fn restored_paper_keeps_preview_and_saved_sheet_dimensions() { fn settle(h: &mut Harness<'static, PdfCraftApp>) { for _ in 0..500 { h.run_steps(1); - if !h.state().print_draft.printers_loading { + if !h.state().print_draft.printers_loading && !h.state().print_draft.papers_loading() { break; } std::thread::sleep(std::time::Duration::from_millis(2)); } assert!(!h.state().print_draft.printers_loading, "the printer list loaded"); + assert!(!h.state().print_draft.papers_loading(), "the printer papers loaded"); 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. @@ -1134,6 +1617,8 @@ fn a_custom_paper_prints_at_its_size_and_its_printer_keeps_it() { let attach = |h: &mut Harness<'static, PdfCraftApp>| { 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(|_| Vec::new())); + h.state_mut().default_paper_source = Some(Arc::new(|_| None)); let sent = sent.clone(); h.state_mut().spooler = Some(Arc::new(move |pdf: &[u8], _job: &Job| { sent.lock().unwrap().push(pdf.to_vec()); @@ -1160,6 +1645,11 @@ fn a_custom_paper_prints_at_its_size_and_its_printer_keeps_it() { assert!((w - 288.0).abs() < 0.01 && (l - 432.0).abs() < 0.01, "portrait: {w}×{l}"); } // The custom row has closed: let the dialog settle at its new height before clicking. + 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)); + } h.run_steps(8); h.get_by(|n| n.label().as_deref() == Some("Print") && n.role() == Role::Button).click(); for _ in 0..400 { @@ -1252,6 +1742,48 @@ fn a_booklet_decides_two_sided_itself() { 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"); +} + /// Creep the field allows can still move a thick booklet's innermost pages too far; the preview /// says to reduce it rather than showing no sheets without a reason (#6777). #[test] @@ -1402,6 +1934,8 @@ fn each_printer_keeps_what_it_last_printed_with_across_a_restart() { .map(|name| Printer { name: name.into(), default: name == "Office", two_sided: None, description: String::new() }) .collect() })); + h.state_mut().printer_papers_source = Some(Arc::new(|_| Vec::new())); + h.state_mut().default_paper_source = Some(Arc::new(|_| None)); let sent = jobs.clone(); h.state_mut().spooler = Some(Arc::new(move |_pdf: &[u8], job: &Job| { sent.lock().unwrap().push(job.clone()); @@ -1412,15 +1946,22 @@ fn each_printer_keeps_what_it_last_printed_with_across_a_restart() { h.state_mut().dialog = 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 && std::time::Instant::now() < deadline { + while (h.state().print_draft.printers_loading || 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.printers_loading); + assert!(!h.state().print_draft.papers_loading()); h.run_steps(2); }; let print = |h: &mut Harness<'static, PdfCraftApp>| -> Job { let before = jobs.lock().unwrap().len(); + 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)); + } + h.run_steps(2); h.get_by(|n| n.label().as_deref() == Some("Print") && n.role() == Role::Button).click(); for _ in 0..400 { h.run_steps(1); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 4587e91425..342d0bba8c 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -8404,9 +8404,20 @@ tier = "P1" milestone = "M10" status = "partial" commands = ["print.dialog"] -tools = ["doc_print"] -evidence = ["crates/ui-egui/tests/it/print.rs::a_custom_paper_prints_at_its_size_and_its_printer_keeps_it", "crates/ui-egui/src/workflows/print_paper.rs::the_paper_list_is_the_standard_sizes_then_the_custom_ones", "crates/ui-egui/src/workflows/print_paper.rs::settings_are_read_as_untrusted", "crates/ui-egui/src/workflows/print_ui/setups.rs::setups_round_trip_through_the_settings_file", "crates/ui-egui/tests/it/print_custom_paper_names.rs::a_custom_paper_cannot_take_a_standard_name", "crates/automation/tests/it/automation.rs::doc_print_takes_a_paper_size_of_its_own"] -notes = "In the Print dialog, as the rest of printing is (no separate Page Setup window): Custom… beside Paper adds a named size in the preferences' unit (1 to 200 inches a side), kept with the settings and removable. A printer printed to on a custom paper starts on it next time, with the rest of its setup (#2001), the paper kept by name. doc_print takes sizes such as 8.5x14in or 210x297mm. Missing: margins." +tools = ["doc_print", "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", + "crates/print/src/tests.rs::overlapping_paper_discovery_two_sided_and_queue_queries", + "crates/ui-egui/tests/it/print.rs::a_custom_paper_prints_at_its_size_and_its_printer_keeps_it", + "crates/ui-egui/src/workflows/print_paper.rs::the_paper_list_is_the_standard_sizes_then_the_custom_ones", + "crates/ui-egui/src/workflows/print_paper.rs::settings_are_read_as_untrusted", + "crates/ui-egui/src/workflows/print_ui/setups.rs::setups_round_trip_through_the_settings_file", + "crates/ui-egui/tests/it/print_custom_paper_names.rs::a_custom_paper_cannot_take_a_standard_name", + "crates/automation/tests/it/automation.rs::doc_print_takes_a_paper_size_of_its_own", +] +notes = "In the Print dialog: Custom… beside Paper adds a named size in the preferences' unit (1 to 200 inches a side), kept with the settings and removable. A printer printed to on a custom paper starts on it next time (#2001). The 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. doc_print takes sizes such as 8.5x14in or 210x297mm or driver papers. Missing: margins." [[feature]] id = "print.print-ready-pdf"