mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-10 08:51:41 +00:00
Report Windows driver duplex capability to print callers
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
+17
-8
@@ -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<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
@@ -91,7 +93,7 @@ pub fn parse_lpstat(out: &str) -> Vec<Printer> {
|
||||
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<Printer> {
|
||||
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<Printer> {
|
||||
let mut printers: Vec<Printer> = 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;
|
||||
|
||||
@@ -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<String, Str
|
||||
#[test]
|
||||
fn windows_lists_the_printers_the_system_knows() {
|
||||
let printers = spool::printers();
|
||||
assert!(printers.iter().all(|p| !p.name.trim().is_empty()), "{printers:?}");
|
||||
assert!(printers.iter().all(|p| !p.name.trim().is_empty() && p.two_sided.is_some()), "{printers:?}");
|
||||
assert!(printers.iter().filter(|p| p.default).count() <= 1, "{printers:?}");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user