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