From d991bf6792f8ade99c51358bf3c1d902c8cd96b5 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Mon, 12 Oct 2026 01:04:26 +0200 Subject: [PATCH] Report Windows driver duplex capability to print callers --- NOTICE | 3 +++ crates/print/README.md | 3 +++ crates/print/src/lib.rs | 25 +++++++++++++++++-------- crates/print/src/tests.rs | 28 +++++++++++++++++++--------- 4 files changed, 42 insertions(+), 17 deletions(-) diff --git a/NOTICE b/NOTICE index c0a1872a..03fcc7fb 100644 --- a/NOTICE +++ b/NOTICE @@ -49,3 +49,6 @@ The PDF spooler in crates/print is extracted from: https://github.com/storytold/pdfcraft Copyright (c) 2026 ArtCraft Team and the PdfCraft contributors. Licensed MIT OR Apache-2.0. PDF composition and application code are not copied. + +The Windows printer duplex-capability query and parser additionally draw on PdfCraft, +crates/print/src/spool.rs, revision fb97ce958dfe92b6762ce40615c7836551710139 (PR #1154), under the same licenses above. diff --git a/crates/print/README.md b/crates/print/README.md index cdec5634..f5a44fa7 100644 --- a/crates/print/README.md +++ b/crates/print/README.md @@ -48,3 +48,6 @@ The cross-platform GitHub Actions workflow is defined in `.github/workflows/prin It runs formatting, Clippy and this crate's own tests on Windows, Linux and macOS when print code changes, and supports manual runs. The Windows job executes the WinRT and spooler regressions above; physical printer fidelity still requires hardware verification. + +Windows discovery reports driver duplex support in `Printer::two_sided`; CUPS leaves it unknown. +Callers can warn when a Windows driver ignores the job’s duplex choice. diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index f8ff9a77..beb5a8bb 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -29,6 +29,8 @@ impl std::error::Error for Error {} pub struct Printer { pub name: String, pub default: bool, + /// Whether the Windows driver reports duplex support; None when unknown (CUPS). + pub two_sided: Option, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] @@ -91,7 +93,7 @@ pub fn parse_lpstat(out: &str) -> Vec { out.lines() .filter_map(|l| l.strip_prefix("printer ")) .filter_map(|l| l.split_whitespace().next()) - .map(|n| Printer { name: n.to_string(), default: default == Some(n) }) + .map(|n| Printer { name: n.to_string(), default: default == Some(n), two_sided: None }) .collect() } @@ -383,7 +385,7 @@ pub fn printers_parsed(queues: &str, available: Option<&str>) -> Vec { if let Some(names) = available { for name in parse_lpstat_e(names) { if !printers.iter().any(|p| p.name == name) { - printers.push(Printer { default: default == Some(name.as_str()), name }); + printers.push(Printer { default: default == Some(name.as_str()), name, two_sided: None }); } } } @@ -405,22 +407,29 @@ fn powershell_command(script: &str) -> std::process::Command { } /// The Windows printer list: one line per printer, `*` on the system default. UTF-8, through -/// the output encoding the script sets first. Only the two properties the list needs are +/// the output encoding the script sets first. Only the properties the list needs are /// pulled, so a machine with many printers doesn't wait for the rest of each record. #[cfg(windows)] -const POWERSHELL_PRINTERS: &str = "[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false); Get-CimInstance Win32_Printer -Property Name, Default | ForEach-Object { if ($_.Default) { '*' + $_.Name } else { $_.Name } }"; +const POWERSHELL_PRINTERS: &str = "[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false); Get-CimInstance Win32_Printer -Property Name, Default, Capabilities | ForEach-Object { $sides = if (@($_.Capabilities) -contains 3) { '2' } else { '1' }; if ($_.Default) { '*' + $_.Name + [char]9 + $sides } else { $_.Name + [char]9 + $sides } }"; /// The Print dialog's printers from the Windows printer list: one per line, `*` before the -/// system default (a Windows queue name may not contain `*`). The first `*` wins when a broken +/// system default, followed by a tab and 2/1 for duplex support. The first `*` wins when a broken /// spooler marks more than one, so the dialog still has a single default to preselect. pub fn parse_windows_printers(out: &str) -> Vec { let mut printers: Vec = out .lines() .map(|l| l.strip_suffix('\r').unwrap_or(l)) .filter(|l| !l.is_empty()) - .map(|l| match l.strip_prefix('*') { - Some(name) => Printer { name: name.to_string(), default: true }, - None => Printer { name: l.to_string(), default: false }, + .map(|l| { + let (l, two_sided) = match l.rsplit_once('\t') { + Some((name, "2")) => (name, Some(true)), + Some((name, "1")) => (name, Some(false)), + _ => (l, None), + }; + match l.strip_prefix('*') { + Some(name) => Printer { name: name.to_string(), default: true, two_sided }, + None => Printer { name: l.to_string(), default: false, two_sided }, + } }) .collect(); let mut seen_default = false; diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index 019e5828..8d04f7cd 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -6,7 +6,10 @@ fn spooler_arguments_and_printer_list() { let out = "printer Office_Laser is idle. enabled since Thu Oct 1 09:00:00 2026\nprinter Label_Writer disabled since …\nsystem default destination: Office_Laser\n"; assert_eq!( parse_lpstat(out), - [spool::Printer { name: "Office_Laser".into(), default: true }, spool::Printer { name: "Label_Writer".into(), default: false }] + [ + spool::Printer { name: "Office_Laser".into(), default: true, two_sided: None }, + spool::Printer { name: "Label_Writer".into(), default: false, two_sided: None } + ] ); assert!(parse_lpstat("lpstat: No destinations added.\nno system default destination\n").is_empty()); let job = Job { @@ -47,16 +50,19 @@ fn driverless_printers_join_the_list_without_a_queue() { let queues = "printer Office_Laser is idle. enabled since Thu Oct 1 09:00:00 2026\nsystem default destination: Office_Laser\n"; assert_eq!( spool::printers_parsed(queues, Some("Basement_Color\nOffice_Laser\n")), - [spool::Printer { name: "Office_Laser".into(), default: true }, spool::Printer { name: "Basement_Color".into(), default: false },], + [ + spool::Printer { name: "Office_Laser".into(), default: true, two_sided: None }, + spool::Printer { name: "Basement_Color".into(), default: false, two_sided: None }, + ], "queues first, then the driverless destination; the queue listed twice is not doubled" ); // An old spooler without `-e` (CUPS < 1.7) keeps its queues; only the driverless names are lost. - assert_eq!(spool::printers_parsed(queues, None), [spool::Printer { name: "Office_Laser".into(), default: true }]); + assert_eq!(spool::printers_parsed(queues, None), [spool::Printer { name: "Office_Laser".into(), default: true, two_sided: None }]); // The default can itself be driverless (`lpoptions -d` names a destination no queue exists // for): it keeps its marker, so the dialog preselects the user's default, not the first queue. assert_eq!( spool::printers_parsed("system default destination: Basement_Color\n", Some("Basement_Color\n")), - [spool::Printer { name: "Basement_Color".into(), default: true }], + [spool::Printer { name: "Basement_Color".into(), default: true, two_sided: None }], "a default destination without a queue is still the default" ); assert!(spool::parse_lpstat_e("\n \n").is_empty(), "no destinations is empty, not [\"\"]"); @@ -148,12 +154,16 @@ fn windows_printers_carry_one_default_and_keep_their_names() { // Windows marks the system default with a `*` no queue name may contain; names hold spaces, // parentheses and non-ASCII letters, and the lines end with CRLF. let printers = parse_windows_printers("*Microsoft Print to PDF\r\nHP LaserJet 400 (Copy 2)\r\nКопир Canon iR\r\n\r\n*XPS via USB\r\n"); - assert_eq!(printers[0], Printer { name: "Microsoft Print to PDF".into(), default: true }); - assert_eq!(printers[1], Printer { name: "HP LaserJet 400 (Copy 2)".into(), default: false }); - assert_eq!(printers[2], Printer { name: "Копир Canon iR".into(), default: false }); + assert_eq!(printers[0], Printer { name: "Microsoft Print to PDF".into(), default: true, two_sided: None }); + assert_eq!(printers[1], Printer { name: "HP LaserJet 400 (Copy 2)".into(), default: false, two_sided: None }); + assert_eq!(printers[2], Printer { name: "Копир Canon iR".into(), default: false, two_sided: None }); // A spooler marking two defaults: the first still wins, so the dialog has one to preselect. - assert_eq!(printers[3], Printer { name: "XPS via USB".into(), default: false }); + assert_eq!(printers[3], Printer { name: "XPS via USB".into(), default: false, two_sided: None }); assert_eq!(parse_windows_printers("\r\n"), Vec::new()); + let sided = parse_windows_printers("*Office PCL\t2\r\nOffice PS\t1\r\nTab\tName\t2\r\n"); + assert_eq!(sided[0], Printer { name: "Office PCL".into(), default: true, two_sided: Some(true) }); + assert_eq!(sided[1], Printer { name: "Office PS".into(), default: false, two_sided: Some(false) }); + assert_eq!(sided[2], Printer { name: "Tab\tName".into(), default: false, two_sided: Some(true) }); } #[test] @@ -209,7 +219,7 @@ fn windows_powershell(script: &str, envs: &[(&str, &str)]) -> Result