forked from 1b-agent/opencadstudio-upstream
fix(print): complete Windows printer discovery, honor the true default, surface failures; make CTB loading usable (#1261)
Printer enumeration used EnumPrinters level 4 only, which is served from the local registry cache and misses Win11 per-user network printers; an empty result now retries with level 2 (spooler RPC) and merges the resolved default. The dialog's 'System default printer' used to delegate to the shell print verb — the default PDF handler's own printer choice, not the system default; it now resolves GetDefaultPrinterW and dispatches printto naming that printer explicitly. Every dispatch probes OpenPrinterW first, so a printer that vanished between listing and printing produces a clear error instead of ShellExecute's silent nothing, and all SE_ERR_* codes carry readable reasons. Legacy CTB: picking an unparseable table used to swallow the failure (.ok()) — the user saw nothing at all. Load errors now reach the command line, the picker accepts .stb alongside .ctb (the container gains the STBVER1 header; .stb application errors state the actual reason), and a table loaded from anywhere is copied into the user's plot-styles folder so it persists in the dialog, matching AutoCAD. Buffer parsing for both EnumPrinters layouts is unit-tested; the missing-printer path was exercised against the real spooler (existence probe only, no job).
This commit is contained in:
1 parent
02d9273a19
commit
5ea4294d3d
6 files changed
+390
-44
No files matched your search
+3
-1
@@ -62,7 +62,9 @@ fluent-syntax = "0.12"
|
||||
naga = { version = "27", features = ["wgsl-in"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_System_Com", "Win32_System_Registry", "Win32_Storage_FileSystem", "Win32_Foundation", "Win32_Graphics_Printing"] }
|
||||
# Win32_Graphics_Gdi + Win32_Security are required for PRINTER_INFO_2W (the
|
||||
# level-2 printer enumeration fallback in src/io/print_to_printer.rs).
|
||||
windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_System_Com", "Win32_System_Registry", "Win32_Storage_FileSystem", "Win32_Foundation", "Win32_Graphics_Printing", "Win32_Graphics_Gdi", "Win32_Security"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
ashpd = { version = "0.13.13", default-features = false, features = ["async-io", "wayland"] }
|
||||
|
||||
+2
-1
@@ -3290,7 +3290,8 @@ pub enum Message {
|
||||
/// Open file dialog to load a CTB/STB plot style table.
|
||||
PlotStyleLoad,
|
||||
/// Callback when the user picks (or cancels) a CTB/STB file.
|
||||
PlotStyleLoaded(Option<crate::io::plot_style::PlotStyleTable>),
|
||||
/// `Ok(None)` = cancelled; `Err` = the picked file failed to parse.
|
||||
PlotStyleLoaded(Result<Option<crate::io::plot_style::PlotStyleTable>, String>),
|
||||
/// Clear the active plot style table.
|
||||
PlotStyleClear,
|
||||
/// Open/close the Plot Style panel.
|
||||
|
||||
@@ -8340,7 +8340,7 @@ impl OpenCADStudio {
|
||||
Message::PlotStyleLoad => {
|
||||
Task::perform(crate::io::pick_plot_style(), Message::PlotStyleLoaded)
|
||||
}
|
||||
Message::PlotStyleLoaded(Some(table)) => {
|
||||
Message::PlotStyleLoaded(Ok(Some(table))) => {
|
||||
if table.is_stb {
|
||||
self.command_line.push_error(
|
||||
crate::t!(
|
||||
@@ -8368,7 +8368,14 @@ impl OpenCADStudio {
|
||||
self.plot_dialog.plot_styles = crate::io::plot_style::available_ctb_names();
|
||||
Task::none()
|
||||
}
|
||||
Message::PlotStyleLoaded(None) => Task::none(),
|
||||
// The user closed the file dialog without picking anything.
|
||||
Message::PlotStyleLoaded(Ok(None)) => Task::none(),
|
||||
// A picked table that failed to parse used to vanish silently
|
||||
// (`.ok()` collapsed it into "cancelled"); surface it instead.
|
||||
Message::PlotStyleLoaded(Err(error)) => {
|
||||
self.command_line.push_error(&error);
|
||||
Task::none()
|
||||
}
|
||||
Message::PlotStyleClear => {
|
||||
self.active_plot_style = None;
|
||||
self.plot_dialog.style_name.clear();
|
||||
|
||||
+33
-5
@@ -1523,19 +1523,47 @@ mod save_failure_tests {
|
||||
// ── Plot Style Table ──────────────────────────────────────────────────────
|
||||
|
||||
/// Show a file-open dialog and load the selected CTB or STB file.
|
||||
pub async fn pick_plot_style() -> Option<plot_style::PlotStyleTable> {
|
||||
/// `Ok(None)` means the user cancelled the dialog; `Err` carries a parse
|
||||
/// failure so it reaches the command line instead of dying silently (the
|
||||
/// legacy-CTB complaint in #1261d was partly that a file which failed to
|
||||
/// parse produced no feedback at all).
|
||||
pub async fn pick_plot_style() -> Result<Option<plot_style::PlotStyleTable>, String> {
|
||||
let dialog = crate::sys::file_dialog()
|
||||
.set_title(crate::t!("Load Plot Style Table").as_ref())
|
||||
.add_filter(crate::t!("Plot Style Tables").as_ref(), &["ctb", "CTB"])
|
||||
.add_filter(crate::t!("CTB Files").as_ref(), &["ctb", "CTB"])
|
||||
.add_filter(
|
||||
crate::t!("Plot Style Tables").as_ref(),
|
||||
&["ctb", "stb", "CTB", "STB"],
|
||||
)
|
||||
.add_filter(crate::t!("All Files").as_ref(), &["*"]);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let dialog = match plot_style::ensure_plot_styles_dir() {
|
||||
Ok(dir) => dialog.set_directory(dir),
|
||||
Err(_) => dialog,
|
||||
};
|
||||
let handle = dialog.pick_file().await?;
|
||||
plot_style::PlotStyleTable::load(&crate::sys::handle_path(&handle)).ok()
|
||||
let Some(handle) = dialog.pick_file().await else {
|
||||
return Ok(None);
|
||||
};
|
||||
let path = crate::sys::handle_path(&handle);
|
||||
let table = plot_style::PlotStyleTable::load(&path).map_err(|error| {
|
||||
crate::tf!(
|
||||
"Could not load plot style table '{}': {error}",
|
||||
path.display()
|
||||
)
|
||||
.into_owned()
|
||||
})?;
|
||||
// Loading a table registers it, the way AutoCAD does: copy it next to the
|
||||
// built-ins so it stays in the Plot dialog's table list after restart.
|
||||
// Failures are non-fatal — the table is already applied to this plot.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if !table.is_stb {
|
||||
if let (Ok(dir), Some(file_name)) = (plot_style::plot_styles_dir(), path.file_name()) {
|
||||
let registered = dir.join(file_name);
|
||||
if !registered.exists() {
|
||||
let _ = std::fs::copy(&path, ®istered);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Some(table))
|
||||
}
|
||||
|
||||
// ── Image file picker ─────────────────────────────────────────────────────
|
||||
|
||||
+41
-5
@@ -264,12 +264,22 @@ impl PlotStyleTable {
|
||||
let path = Path::new(name);
|
||||
if path.components().count() != 1
|
||||
|| !matches!(path.components().next(), Some(Component::Normal(_)))
|
||||
|| !path
|
||||
.extension()
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("ctb"))
|
||||
|| !path.extension().is_some_and(|ext| {
|
||||
ext.eq_ignore_ascii_case("ctb") || ext.eq_ignore_ascii_case("stb")
|
||||
})
|
||||
{
|
||||
return Err(format!("Invalid plot style name: {name}"));
|
||||
}
|
||||
// STB (named) tables parse but the plotter is color-dependent, so
|
||||
// refuse them with the actual reason instead of a generic error.
|
||||
if path
|
||||
.extension()
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("stb"))
|
||||
{
|
||||
return Err(format!(
|
||||
"Named plot style tables (.stb) cannot be applied to color-dependent plots: {name}"
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
@@ -427,9 +437,14 @@ impl PlotStyleTable {
|
||||
/// Decompress a CTB/STB file's raw bytes into the text content.
|
||||
///
|
||||
fn decompress_ctb(data: &[u8]) -> Result<String, String> {
|
||||
const PREFIX: &[u8] = b"PIAFILEVERSION_2.0,CTBVER1,compress\r\npmzlibcodec";
|
||||
// CTB and STB files share this container; only the embedded version tag
|
||||
// differs ("CTBVER1" vs "STBVER1"). Accept both so real AutoCAD STB files
|
||||
// take the structured path instead of falling through to the raw
|
||||
// deflate heuristic below (where they fail with a confusing zlib error).
|
||||
const CTB_PREFIX: &[u8] = b"PIAFILEVERSION_2.0,CTBVER1,compress\r\npmzlibcodec";
|
||||
const STB_PREFIX: &[u8] = b"PIAFILEVERSION_2.0,STBVER1,compress\r\npmzlibcodec";
|
||||
let mut decoded = Vec::new();
|
||||
if data.starts_with(PREFIX) {
|
||||
if data.starts_with(CTB_PREFIX) || data.starts_with(STB_PREFIX) {
|
||||
if data.len() < 60 {
|
||||
return Err("CTB header is truncated".into());
|
||||
}
|
||||
@@ -764,6 +779,27 @@ mod round_trip_tests {
|
||||
PlotStyleTable::from_bytes(table.name.clone(), &bytes).expect("reload")
|
||||
}
|
||||
|
||||
/// Real AutoCAD .stb files use the same container with an `STBVER1` tag;
|
||||
/// they must decompress instead of dying in the deflate fallback.
|
||||
#[test]
|
||||
fn stb_container_headers_parse() {
|
||||
let mut bytes =
|
||||
compress_ctb(PlotStyleTable::identity("x").to_text().as_bytes()).expect("compress");
|
||||
// "PIAFILEVERSION_2.0," is 18 bytes, so 19..22 holds the "CTB" tag.
|
||||
bytes[19..22].copy_from_slice(b"STB");
|
||||
let table = PlotStyleTable::from_bytes("mystyles.stb", &bytes).expect("stb parses");
|
||||
assert!(table.is_stb);
|
||||
}
|
||||
|
||||
/// Named tables are refused with the actual limitation, not a generic
|
||||
/// invalid-name error (and without touching the file system).
|
||||
#[test]
|
||||
fn load_named_rejects_stb_with_reason() {
|
||||
let error = PlotStyleTable::load_named("mystyles.stb").unwrap_err();
|
||||
assert!(error.contains("stb"), "{error}");
|
||||
assert!(error.contains("color-dependent"), "{error}");
|
||||
}
|
||||
|
||||
/// Saving a table keeps its global scale factor, which scales every
|
||||
/// lineweight while apply_factor is on. It used to be cut to one decimal.
|
||||
#[test]
|
||||
|
||||
+302
-30
@@ -3,7 +3,8 @@
|
||||
// Desktop strategy:
|
||||
// 1. Render the drawing to a temporary PDF (reusing the PDF export pipeline).
|
||||
// 2. Send that PDF to the system printer with `lp` (Linux/macOS) or
|
||||
// `ShellExecute PRINT` (Windows).
|
||||
// `ShellExecute PRINTTO` on Windows, naming the exact target printer —
|
||||
// including the spooler's own default when none was selected.
|
||||
//
|
||||
// The function is async so the UI remains responsive while the job is queued.
|
||||
//
|
||||
@@ -108,11 +109,66 @@ pub fn list_printers() -> Vec<String> {
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn windows_printers() -> std::io::Result<Vec<String>> {
|
||||
use windows_sys::Win32::Foundation::{GetLastError, ERROR_INSUFFICIENT_BUFFER};
|
||||
use windows_sys::Win32::Graphics::Printing::{
|
||||
EnumPrintersW, PRINTER_ENUM_CONNECTIONS, PRINTER_ENUM_LOCAL,
|
||||
PRINTER_ENUM_CONNECTIONS, PRINTER_ENUM_LOCAL, PRINTER_INFO_2W, PRINTER_INFO_4W,
|
||||
};
|
||||
|
||||
// Level 4 is served from the local registry cache and returns instantly,
|
||||
// but on Windows 11 it can miss per-user network printers that only the
|
||||
// spooler RPC (level 2) reports (#1261a). The level-2 query is expensive —
|
||||
// it opens every remote connection and can wait out RPC timeouts on dead
|
||||
// print servers — so it only runs when the cached query came back empty;
|
||||
// machines whose level-4 list works never pay for it. (The flags stay
|
||||
// LOCAL|CONNECTIONS on purpose: the EnumPrintersW documentation restricts
|
||||
// level 4 to exactly those flags, and PRINTER_ENUM_NETWORK is only valid
|
||||
// with level 1, so completeness comes from the level-2 retry, not flags.)
|
||||
let mut names = enum_printer_names(
|
||||
4,
|
||||
PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS,
|
||||
std::mem::size_of::<PRINTER_INFO_4W>(),
|
||||
// pPrinterName is the first field of PRINTER_INFO_4W.
|
||||
0,
|
||||
)?;
|
||||
if names.is_empty() {
|
||||
names = enum_printer_names(
|
||||
2,
|
||||
PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS,
|
||||
std::mem::size_of::<PRINTER_INFO_2W>(),
|
||||
// pPrinterName is the second pointer field of PRINTER_INFO_2W.
|
||||
std::mem::size_of::<usize>(),
|
||||
)?;
|
||||
}
|
||||
// The spooler's own default is authoritative even when enumeration missed
|
||||
// it; always offer it explicitly so the list can never hide it (#1261b).
|
||||
if let Some(default) = windows_default_printer() {
|
||||
if !names.iter().any(|name| name.eq_ignore_ascii_case(&default)) {
|
||||
names.push(default);
|
||||
}
|
||||
}
|
||||
// Windows treats printer names case-insensitively, so merge away case
|
||||
// variants between the two queries as well.
|
||||
names.sort_by_key(|name| name.to_ascii_lowercase());
|
||||
names.dedup_by(|left, right| left.eq_ignore_ascii_case(right));
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
/// Query the spooler with `EnumPrintersW` at `level` and return the sorted,
|
||||
/// deduplicated printer names. `record_bytes` is the byte size of the level's
|
||||
/// `PRINTER_INFO_*W` record and `name_offset` the byte offset of its
|
||||
/// `pPrinterName` field.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn enum_printer_names(
|
||||
level: u32,
|
||||
flags: u32,
|
||||
record_bytes: usize,
|
||||
name_offset: usize,
|
||||
) -> std::io::Result<Vec<String>> {
|
||||
use windows_sys::Win32::Foundation::{GetLastError, ERROR_INSUFFICIENT_BUFFER};
|
||||
use windows_sys::Win32::Graphics::Printing::EnumPrintersW;
|
||||
|
||||
debug_assert!(record_bytes % std::mem::size_of::<usize>() == 0);
|
||||
debug_assert!(name_offset % std::mem::size_of::<usize>() == 0);
|
||||
|
||||
let mut bytes_needed = 0;
|
||||
let mut printer_count = 0;
|
||||
let mut buffer = Vec::<usize>::new();
|
||||
@@ -129,9 +185,9 @@ fn windows_printers() -> std::io::Result<Vec<String>> {
|
||||
// aligned, writable allocation of buffer_bytes bytes.
|
||||
let success = unsafe {
|
||||
EnumPrintersW(
|
||||
PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS,
|
||||
flags,
|
||||
std::ptr::null(),
|
||||
4,
|
||||
level,
|
||||
data,
|
||||
buffer_bytes,
|
||||
&mut bytes_needed,
|
||||
@@ -139,7 +195,12 @@ fn windows_printers() -> std::io::Result<Vec<String>> {
|
||||
)
|
||||
};
|
||||
if success != 0 {
|
||||
return windows_printer_names(&buffer, printer_count as usize);
|
||||
return windows_printer_names(
|
||||
&buffer,
|
||||
printer_count as usize,
|
||||
record_bytes,
|
||||
name_offset,
|
||||
);
|
||||
}
|
||||
let error = unsafe { GetLastError() };
|
||||
if error != ERROR_INSUFFICIENT_BUFFER || bytes_needed <= buffer_bytes {
|
||||
@@ -156,28 +217,37 @@ fn windows_printers() -> std::io::Result<Vec<String>> {
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn windows_printer_names(buffer: &[usize], printer_count: usize) -> std::io::Result<Vec<String>> {
|
||||
fn windows_printer_names(
|
||||
buffer: &[usize],
|
||||
printer_count: usize,
|
||||
record_bytes: usize,
|
||||
name_offset: usize,
|
||||
) -> std::io::Result<Vec<String>> {
|
||||
use std::io::{Error, ErrorKind};
|
||||
use windows_sys::Win32::Graphics::Printing::PRINTER_INFO_4W;
|
||||
|
||||
const {
|
||||
assert!(std::mem::align_of::<usize>() >= std::mem::align_of::<PRINTER_INFO_4W>());
|
||||
if record_bytes == 0 || name_offset + std::mem::size_of::<usize>() > record_bytes {
|
||||
return Err(Error::from(ErrorKind::InvalidInput));
|
||||
}
|
||||
let buffer_bytes = std::mem::size_of_val(buffer);
|
||||
if printer_count > buffer_bytes / std::mem::size_of::<PRINTER_INFO_4W>() {
|
||||
if printer_count > buffer_bytes / record_bytes {
|
||||
return Err(Error::from(ErrorKind::InvalidData));
|
||||
}
|
||||
// SAFETY: the initialized buffer is aligned and large enough for these
|
||||
// records. Raw pointer fields are validated before reading any names.
|
||||
let records = unsafe {
|
||||
std::slice::from_raw_parts(buffer.as_ptr().cast::<PRINTER_INFO_4W>(), printer_count)
|
||||
};
|
||||
let base = buffer.as_ptr().cast::<u8>();
|
||||
let mut names = Vec::with_capacity(printer_count);
|
||||
for record in records {
|
||||
if record.pPrinterName.is_null() {
|
||||
for index in 0..printer_count {
|
||||
let field = index * record_bytes + name_offset;
|
||||
if field > buffer_bytes || buffer_bytes - field < std::mem::size_of::<usize>() {
|
||||
return Err(Error::from(ErrorKind::InvalidData));
|
||||
}
|
||||
// SAFETY: the field lies inside the buffer; `read_unaligned` accepts
|
||||
// any address, and PRINTER_INFO_4W/_2W layouts keep their leading
|
||||
// pointer fields at word multiples so this reads the real pointer.
|
||||
let printer_name =
|
||||
unsafe { base.add(field).cast::<usize>().read_unaligned() } as *const u16;
|
||||
if printer_name.is_null() {
|
||||
continue;
|
||||
}
|
||||
let offset = (record.pPrinterName as usize)
|
||||
let offset = (printer_name as usize)
|
||||
.checked_sub(buffer.as_ptr() as usize)
|
||||
.filter(|offset| *offset < buffer_bytes && offset % 2 == 0)
|
||||
.ok_or(Error::from(ErrorKind::InvalidData))?;
|
||||
@@ -201,15 +271,118 @@ fn windows_printer_names(buffer: &[usize], printer_count: usize) -> std::io::Res
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
/// The spooler's configured default printer, if any. `GetDefaultPrinterW` is
|
||||
/// the only source that matches what every other Windows application means by
|
||||
/// "default printer" (#1261b): the shell `print` verb instead hands the file
|
||||
/// to the registered PDF handler, which applies its own — often different —
|
||||
/// default, or just opens the viewer (#1261c).
|
||||
#[cfg(target_os = "windows")]
|
||||
fn windows_default_printer() -> Option<String> {
|
||||
use windows_sys::Win32::Foundation::{GetLastError, ERROR_INSUFFICIENT_BUFFER};
|
||||
use windows_sys::Win32::Graphics::Printing::GetDefaultPrinterW;
|
||||
|
||||
let mut capacity = 0u32;
|
||||
// First call sizes the buffer: it fails with ERROR_INSUFFICIENT_BUFFER
|
||||
// when a default exists (capacity then includes the NUL terminator), and
|
||||
// with e.g. ERROR_FILE_NOT_FOUND when no default is configured at all.
|
||||
if unsafe { GetDefaultPrinterW(std::ptr::null_mut(), &mut capacity) } != 0 {
|
||||
return None;
|
||||
}
|
||||
if unsafe { GetLastError() } != ERROR_INSUFFICIENT_BUFFER || capacity == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut buffer = vec![0u16; capacity as usize];
|
||||
if unsafe { GetDefaultPrinterW(buffer.as_mut_ptr(), &mut capacity) } == 0 {
|
||||
return None;
|
||||
}
|
||||
let length = buffer.iter().position(|&unit| unit == 0).unwrap_or(0);
|
||||
let name = String::from_utf16_lossy(&buffer[..length]);
|
||||
let trimmed = name.trim();
|
||||
(!trimmed.is_empty()).then(|| trimmed.to_string())
|
||||
}
|
||||
|
||||
/// Probe the spooler for `name` before dispatching a job (#1261c).
|
||||
/// `OpenPrinterW` with no `PRINTER_DEFAULTSW` is a pure
|
||||
/// existence/permission check: it spools nothing and shows no UI, and gives a
|
||||
/// decisive error where the PDF handler would otherwise swallow a bad printer
|
||||
/// name while `ShellExecuteExW` still reports success.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn ensure_printer_exists(name: &str) -> Result<(), String> {
|
||||
use std::ffi::OsStr;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use windows_sys::Win32::Foundation::{
|
||||
GetLastError, ERROR_ACCESS_DENIED, ERROR_INVALID_PRINTER_NAME,
|
||||
};
|
||||
use windows_sys::Win32::Graphics::Printing::{ClosePrinter, OpenPrinterW, PRINTER_HANDLE};
|
||||
|
||||
let wide: Vec<u16> = OsStr::new(name).encode_wide().chain(Some(0)).collect();
|
||||
let mut handle = PRINTER_HANDLE::default();
|
||||
// SAFETY: `wide` is NUL-terminated and `handle` is an out-parameter.
|
||||
if unsafe { OpenPrinterW(wide.as_ptr(), &mut handle, std::ptr::null()) } == 0 {
|
||||
let error = unsafe { GetLastError() };
|
||||
return Err(match error {
|
||||
ERROR_INVALID_PRINTER_NAME => {
|
||||
format!("printer '{name}' is not installed on this computer")
|
||||
}
|
||||
ERROR_ACCESS_DENIED => format!("access to printer '{name}' was denied"),
|
||||
error => format!("Windows could not open printer '{name}' (error {error})"),
|
||||
});
|
||||
}
|
||||
if !handle.Value.is_null() {
|
||||
// SAFETY: the handle was just returned by a successful OpenPrinterW.
|
||||
unsafe { ClosePrinter(handle) };
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Human-readable reasons for the failure codes `ShellExecuteExW` reports.
|
||||
/// Per the ShellExecute documentation, `hInstApp` values in 1..=32 are the
|
||||
/// `SE_ERR_*` instance codes; anything else came from `GetLastError`.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn describe_shell_execute_failure(code: u32) -> &'static str {
|
||||
use windows_sys::Win32::Foundation::ERROR_NO_ASSOCIATION;
|
||||
use windows_sys::Win32::UI::Shell::{
|
||||
SE_ERR_ACCESSDENIED, SE_ERR_ASSOCINCOMPLETE, SE_ERR_DDEBUSY, SE_ERR_DDEFAIL,
|
||||
SE_ERR_DDETIMEOUT, SE_ERR_DLLNOTFOUND, SE_ERR_FNF, SE_ERR_NOASSOC, SE_ERR_OOM,
|
||||
SE_ERR_PNF, SE_ERR_SHARE,
|
||||
};
|
||||
match code {
|
||||
0 => "the operating system is out of memory or resources",
|
||||
SE_ERR_FNF => "the rendered PDF file could not be found",
|
||||
SE_ERR_PNF => "the path to the rendered PDF file could not be found",
|
||||
SE_ERR_ACCESSDENIED => "Windows denied access to the print command",
|
||||
SE_ERR_OOM => "not enough memory to start the PDF application",
|
||||
SE_ERR_SHARE => "a sharing violation occurred while opening the PDF",
|
||||
SE_ERR_ASSOCINCOMPLETE => "the PDF file association is invalid or incomplete",
|
||||
SE_ERR_DDETIMEOUT => "the PDF application did not respond in time (DDE timeout)",
|
||||
SE_ERR_DDEFAIL => "the DDE print transaction failed",
|
||||
SE_ERR_DDEBUSY => "the PDF application was busy (DDE busy)",
|
||||
SE_ERR_DLLNOTFOUND => "a DLL required by the print handler is missing",
|
||||
SE_ERR_NOASSOC | ERROR_NO_ASSOCIATION => {
|
||||
"Windows has no PDF application registered with Print support"
|
||||
}
|
||||
_ => "unknown ShellExecute failure",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "windows"))]
|
||||
mod printer_buffer_tests {
|
||||
use super::windows_printer_names;
|
||||
use windows_sys::Win32::Graphics::Printing::PRINTER_INFO_4W;
|
||||
use super::{
|
||||
describe_shell_execute_failure, ensure_printer_exists, windows_default_printer,
|
||||
windows_printer_names,
|
||||
};
|
||||
use windows_sys::Win32::Graphics::Printing::{PRINTER_INFO_2W, PRINTER_INFO_4W};
|
||||
|
||||
#[test]
|
||||
fn printer_names_stay_within_the_returned_buffer() {
|
||||
assert!(windows_printer_names(&[], 0).unwrap().is_empty());
|
||||
assert!(windows_printer_names(&[], 1).is_err());
|
||||
assert!(
|
||||
windows_printer_names(&[], 0, std::mem::size_of::<PRINTER_INFO_4W>(), 0)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
windows_printer_names(&[], 1, std::mem::size_of::<PRINTER_INFO_4W>(), 0).is_err()
|
||||
);
|
||||
let mut buffer = vec![0usize; 16];
|
||||
let name_offset = std::mem::size_of::<PRINTER_INFO_4W>();
|
||||
let name: Vec<u16> = "Printer \u{03b1}\0".encode_utf16().collect();
|
||||
@@ -224,15 +397,84 @@ mod printer_buffer_tests {
|
||||
std::ptr::copy_nonoverlapping(name.as_ptr(), name_ptr, name.len());
|
||||
(*record).pPrinterName = name_ptr;
|
||||
assert_eq!(
|
||||
windows_printer_names(&buffer, 1).unwrap(),
|
||||
windows_printer_names(&buffer, 1, std::mem::size_of::<PRINTER_INFO_4W>(), 0)
|
||||
.unwrap(),
|
||||
["Printer \u{03b1}"]
|
||||
);
|
||||
(*record).pPrinterName = buffer.as_mut_ptr().cast::<u16>().wrapping_sub(1);
|
||||
assert!(windows_printer_names(&buffer, 1).is_err());
|
||||
assert!(
|
||||
windows_printer_names(&buffer, 1, std::mem::size_of::<PRINTER_INFO_4W>(), 0)
|
||||
.is_err()
|
||||
);
|
||||
(*record).pPrinterName = name_ptr;
|
||||
}
|
||||
buffer[name_offset / std::mem::size_of::<usize>()..].fill(usize::MAX);
|
||||
assert!(windows_printer_names(&buffer, 1).is_err());
|
||||
assert!(
|
||||
windows_printer_names(&buffer, 1, std::mem::size_of::<PRINTER_INFO_4W>(), 0).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
/// Level 2 records keep `pPrinterName` in the second field; the name
|
||||
/// reader must honour the layout it is given.
|
||||
#[test]
|
||||
fn level2_names_read_the_second_field() {
|
||||
let record_bytes = std::mem::size_of::<PRINTER_INFO_2W>();
|
||||
let mut buffer = vec![0usize; record_bytes / std::mem::size_of::<usize>() + 8];
|
||||
let name: Vec<u16> = "Net \u{263a}\0".encode_utf16().collect();
|
||||
// SAFETY: the aligned allocation holds the record and UTF-16 name.
|
||||
unsafe {
|
||||
let record = buffer.as_mut_ptr().cast::<PRINTER_INFO_2W>();
|
||||
let name_ptr = buffer
|
||||
.as_mut_ptr()
|
||||
.cast::<u8>()
|
||||
.add(record_bytes)
|
||||
.cast::<u16>();
|
||||
std::ptr::copy_nonoverlapping(name.as_ptr(), name_ptr, name.len());
|
||||
(*record).pServerName = std::ptr::null_mut();
|
||||
(*record).pPrinterName = name_ptr;
|
||||
}
|
||||
assert_eq!(
|
||||
windows_printer_names(&buffer, 1, record_bytes, std::mem::size_of::<usize>())
|
||||
.unwrap(),
|
||||
["Net \u{263a}"]
|
||||
);
|
||||
// Pointing the reader at the wrong offset sees the null pServerName
|
||||
// instead and yields no names.
|
||||
assert!(
|
||||
windows_printer_names(&buffer, 1, record_bytes, 0)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_failures_are_described() {
|
||||
use windows_sys::Win32::Foundation::ERROR_NO_ASSOCIATION;
|
||||
use windows_sys::Win32::UI::Shell::{SE_ERR_DDETIMEOUT, SE_ERR_FNF, SE_ERR_NOASSOC};
|
||||
assert!(describe_shell_execute_failure(SE_ERR_FNF).contains("PDF"));
|
||||
assert!(describe_shell_execute_failure(SE_ERR_DDETIMEOUT).contains("respond"));
|
||||
assert!(describe_shell_execute_failure(SE_ERR_NOASSOC).contains("Print support"));
|
||||
assert!(describe_shell_execute_failure(ERROR_NO_ASSOCIATION).contains("Print support"));
|
||||
assert!(describe_shell_execute_failure(0).contains("memory"));
|
||||
assert!(!describe_shell_execute_failure(9_999).is_empty());
|
||||
}
|
||||
|
||||
/// Opening a printer by name spools nothing — it is a pure spooler
|
||||
/// lookup — so a bogus name must surface a clear error naming it.
|
||||
#[test]
|
||||
fn a_missing_printer_surfaces_a_clear_error() {
|
||||
let error = ensure_printer_exists("ocs-no-such-printer-3f9c").unwrap_err();
|
||||
assert!(error.contains("ocs-no-such-printer-3f9c"), "{error}");
|
||||
}
|
||||
|
||||
/// The machine's real default cannot be asserted in a unit test; check
|
||||
/// only the invariant that a resolved default is a usable name.
|
||||
#[test]
|
||||
fn a_resolved_default_printer_is_a_trimmed_non_empty_name() {
|
||||
if let Some(name) = windows_default_printer() {
|
||||
assert!(!name.is_empty());
|
||||
assert_eq!(name.trim(), name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,9 +658,33 @@ fn dispatch_to_printer_opts(path: &std::path::Path, opts: &PrintOptions) -> Resu
|
||||
|
||||
let wide = |s: &str| -> Vec<u16> { OsStr::new(s).encode_wide().chain(Some(0)).collect() };
|
||||
let path_wide: Vec<u16> = path.as_os_str().encode_wide().chain(Some(0)).collect();
|
||||
let (verb, params, label) = match opts.printer.as_deref() {
|
||||
Some(p) if !p.is_empty() => (wide("printto"), Some(wide(p)), p.to_string()),
|
||||
_ => (wide("print"), None, "default printer".to_string()),
|
||||
// Resolve what the job actually targets (#1261b). The dialog's
|
||||
// "System default printer" arrives as `printer: None`; the old code
|
||||
// then used the shell `print` verb, which defers to the registered
|
||||
// PDF application — whose default printer is frequently not the
|
||||
// system default, or which just opens the viewer. Resolve the
|
||||
// spooler's own default and address it explicitly through `printto`,
|
||||
// exactly like a named printer; only fall back to the bare `print`
|
||||
// verb when Windows itself reports no default printer. This is
|
||||
// resolved once per dispatch so the "Sent to printer: X" message
|
||||
// names the printer that really received the job.
|
||||
let target = opts
|
||||
.printer
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
.map(str::to_string)
|
||||
.or_else(windows_default_printer);
|
||||
// A printer that disappeared since the dialog enumerated it (unplugged
|
||||
// USB device, deleted network share) would be silently handed to the
|
||||
// PDF application: ShellExecute would "succeed" while nothing prints
|
||||
// (#1261c). Probe the spooler first so it fails loudly here instead.
|
||||
if let Some(name) = &target {
|
||||
ensure_printer_exists(name)?;
|
||||
}
|
||||
let (verb, params, label) = match &target {
|
||||
Some(name) => (wide("printto"), Some(wide(name)), name.clone()),
|
||||
None => (wide("print"), None, "default printer".to_string()),
|
||||
};
|
||||
let params_ptr = params
|
||||
.as_ref()
|
||||
@@ -446,7 +712,13 @@ fn dispatch_to_printer_opts(path: &std::path::Path, opts: &PrintOptions) -> Resu
|
||||
"Windows has no PDF application registered with Print support.".into(),
|
||||
);
|
||||
}
|
||||
return Err(format!("Windows print dispatch failed (code {code})"));
|
||||
// Note: a non-zero return from ShellExecuteExW only means the
|
||||
// PDF handler accepted the job; whether it actually spools is
|
||||
// unobservable here without real hardware.
|
||||
return Err(format!(
|
||||
"Windows print dispatch to '{label}' failed (code {code}): {}",
|
||||
describe_shell_execute_failure(code),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(label)
|
||||
|
||||
Reference in new issue
Block a user