Files
OneCAD-Draft/src/io/print_to_printer.rs
T
Hakan SevenandClaude Opus 5.5 ce6bd2c73e fix: bound the image decode transient and keep native picks on disk
Follow-up to the #1717 port:

- The downsample tier holds the decoded RGBA, the resize's vertical-pass
  intermediate (width x new height in RGBA f32) and the output at once;
  that sum now has to fit the decode budget, or the image is refused up
  front instead of overshooting it several times over. The native
  budget drops from 4 GiB to 2 GiB.
- A native IMAGE pick decodes from the picked file instead of copying
  its bytes into the session registry, so an edited file reloads and the
  bytes are not held for the session. The web keeps registering them.
- Reword a plot-preview comment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:02:06 +03:00

2081 lines
79 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// print_to_printer — send the current layout to the system printer.
//
// 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).
//
// The function is async so the UI remains responsive while the job is queued.
//
// The web build has no system printers: `preview_page_pdf` /
// `preview_pages_pdf` render the same PDF bytes, rasterise them, and show
// the pages in an in-page overlay — the plot preview stays on screen
// rather than handing a file to an external viewer.
#[cfg(not(target_arch = "wasm32"))]
use crate::io::pdf_export;
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn temp_pdf_path(kind: &str) -> std::path::PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT_ID: AtomicU64 = AtomicU64::new(0);
let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0);
std::env::temp_dir().join(format!(
"open_cad_studio_{kind}_{}_{stamp}_{id}.pdf",
std::process::id()
))
}
#[cfg(target_arch = "wasm32")]
pub(crate) fn temp_pdf_path(kind: &str) -> std::path::PathBuf {
std::path::PathBuf::from(format!("{kind}.pdf"))
}
/// Extra options for a print job. On CUPS (Linux/macOS) these map to `lp`
/// flags / `-o` options. On Windows the generated PDF already carries render
/// options. Windows queues repeated jobs when more than one copy is requested;
/// driver quality remains managed by the selected printer.
#[derive(Debug, Clone, Default)]
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
pub struct PrintOptions {
/// Target printer name, or `None` for the system default.
pub printer: Option<String>,
/// Number of copies (treated as at least 1).
pub copies: u32,
/// Print quality label selected in the plot dialog. Read only on the CUPS
/// path (`lp -o print-quality=…`); on Windows the driver's own quality
/// setting wins, so the field is legitimately unread there.
#[cfg_attr(target_os = "windows", allow(dead_code))]
pub quality: Option<String>,
/// Driver options chosen in the printer-properties editor, as CUPS
/// `key=value` pairs (`ColorModel=Gray`, `MediaType=Photographic`). Read
/// on the CUPS path (`lp -o`); Windows keeps such choices in the
/// printer's own preferences instead.
#[cfg_attr(target_os = "windows", allow(dead_code))]
pub driver_options: Vec<(String, String)>,
}
#[cfg(target_arch = "wasm32")]
pub fn list_printers() -> Result<Vec<String>, String> {
Ok(Vec::new())
}
#[cfg(target_arch = "wasm32")]
pub async fn print_wires_with(
_page: crate::io::pdf_export::PdfPageInput,
_opts: PrintOptions,
) -> Result<String, String> {
Err("Printing is not available in the web version.".into())
}
#[cfg(target_arch = "wasm32")]
pub fn open_in_viewer(_path: &std::path::Path) -> Result<(), String> {
Err("Preview is not available in the web version.".into())
}
#[cfg(target_arch = "wasm32")]
pub fn print_existing_pdf(_path: &std::path::Path, _opts: &PrintOptions) -> Result<String, String> {
Err("Printing is not available in the web version.".into())
}
/// Enumerate installed printers. Linux/macOS query CUPS via `lpstat -e`;
/// Windows queries the spooler's cached local and connected printer list.
/// An error is the system's own explanation (a stopped spooler, a failing
/// RPC); a desktop without CUPS at all is not an error but an empty list.
#[cfg(not(target_arch = "wasm32"))]
pub fn list_printers() -> Result<Vec<String>, String> {
#[cfg(not(target_os = "windows"))]
{
let output = match std::process::Command::new("lpstat").arg("-e").output() {
Ok(output) => output,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(error) => return Err(error.to_string()),
};
// `lpstat -e` exits non-zero with "No destinations added" when CUPS
// runs but knows no queue — an empty list, not a failure.
Ok(String::from_utf8_lossy(&output.stdout)
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect())
}
#[cfg(target_os = "windows")]
{
windows_printers().map_err(|error| error.to_string())
}
}
/// The printing situation as the system reports it, one line per fact:
/// the default printer, the printers listed (or the reason none were), and
/// on Windows the route a plot takes. Shown by the `PRINTERS` command.
#[cfg(not(target_arch = "wasm32"))]
pub fn printer_report() -> Vec<String> {
let mut lines = Vec::new();
match crate::io::plot_device::default_printer_name() {
Some(name) => lines.push(crate::tf!("Default printer: {name}").into_owned()),
None => lines.push(crate::t!("Default printer: none reported by the system").into_owned()),
}
match list_printers() {
Ok(printers) if printers.is_empty() => {
lines.push(crate::t!("Printers: none listed by the system").into_owned());
}
Ok(printers) => {
let count = printers.len();
lines.push(crate::tf!("Printers ({count}):").into_owned());
lines.extend(printers.into_iter().map(|name| format!(" {name}")));
}
Err(error) => lines.push(crate::tf!("Could not list printers: {error}").into_owned()),
}
#[cfg(target_os = "windows")]
{
match pdf_printto_command() {
Some(command) => lines
.push(crate::tf!("Plots go through the PDF application: {command}").into_owned()),
None => lines.push(
crate::t!(
"Plots go straight to the printer (no PDF application registers a print verb)."
)
.into_owned(),
),
}
}
lines
}
/// What `printer` reports about its sheets, one line per sheet plus the
/// default and the margins — `PRINTERS <name>`. Asks the driver (and caches
/// the answer like the plot dialog does).
#[cfg(not(target_arch = "wasm32"))]
pub fn printer_media_report(printer: &str) -> Vec<String> {
let Some(caps) = crate::io::plot_device::printer_capabilities(printer) else {
return vec![crate::tf!(
"{printer}: the printer reports no sheets; the paper catalogue is used."
)
.into_owned()];
};
let mut lines = Vec::new();
let count = caps.media.len();
lines.push(crate::tf!("{printer}: {count} sheet(s) reported").into_owned());
for media in &caps.media {
let m = media.margins;
let borderless = if media.borderless {
" (borderless available)"
} else {
""
};
lines.push(format!(
" {} — margins L {:.1} B {:.1} R {:.1} T {:.1} mm{borderless}",
media.paper.display(),
m.left,
m.bottom,
m.right,
m.top
));
}
match &caps.default_paper {
Some(paper) => {
let sheet = paper.display();
lines.push(crate::tf!("Default sheet: {sheet}").into_owned());
}
None => lines.push(crate::t!("Default sheet: not reported").into_owned()),
}
lines
}
#[cfg(target_arch = "wasm32")]
pub fn printer_media_report(_printer: &str) -> Vec<String> {
vec![crate::t!("Printing is not available in the web version.").into_owned()]
}
#[cfg(target_arch = "wasm32")]
pub fn printer_report() -> Vec<String> {
vec![crate::t!("Printing is not available in the web version.").into_owned()]
}
/// The Windows default printer, as the spooler names it.
#[cfg(target_os = "windows")]
pub(crate) fn windows_default_printer() -> std::io::Result<String> {
use windows_sys::Win32::Foundation::{GetLastError, ERROR_FILE_NOT_FOUND};
use windows_sys::Win32::Graphics::Printing::GetDefaultPrinterW;
let mut needed = 0u32;
// SAFETY: the first call only asks for the size; the second writes into
// a buffer of exactly that size.
unsafe { GetDefaultPrinterW(std::ptr::null_mut(), &mut needed) };
if needed == 0 {
// Windows answers ERROR_FILE_NOT_FOUND when no printer is the default.
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"No default printer is configured on Windows.",
));
}
let mut buf = vec![0u16; needed as usize];
if unsafe { GetDefaultPrinterW(buf.as_mut_ptr(), &mut needed) } == 0 {
let code = unsafe { GetLastError() };
if code == ERROR_FILE_NOT_FOUND {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"No default printer is configured on Windows.",
));
}
return Err(std::io::Error::from_raw_os_error(code as i32));
}
let end = buf.iter().position(|&u| u == 0).unwrap_or(0);
Ok(String::from_utf16_lossy(&buf[..end]))
}
/// The command Windows runs for the `printto` verb on `.pdf` files — the
/// registered PDF application's "print to a named printer" entry — or
/// `None` when no application registered one (Edge and store readers only
/// register `open`), in which case a plot goes straight to the spooler.
#[cfg(target_os = "windows")]
pub(crate) fn pdf_printto_command() -> Option<String> {
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::UI::Shell::{AssocQueryStringW, ASSOCF_NOTRUNCATE, ASSOCSTR_COMMAND};
let wide = |s: &str| -> Vec<u16> {
std::ffi::OsStr::new(s)
.encode_wide()
.chain(Some(0))
.collect()
};
let extension = wide(".pdf");
let verb = wide("printto");
let mut len = 0u32;
// SAFETY: strings are NUL-terminated; a null buffer asks for the size,
// which the call reports with S_FALSE rather than S_OK.
let sized = unsafe {
AssocQueryStringW(
ASSOCF_NOTRUNCATE,
ASSOCSTR_COMMAND,
extension.as_ptr(),
verb.as_ptr(),
std::ptr::null_mut(),
&mut len,
)
};
if sized < 0 || len == 0 {
return None;
}
let mut buf = vec![0u16; len as usize];
let filled = unsafe {
AssocQueryStringW(
ASSOCF_NOTRUNCATE,
ASSOCSTR_COMMAND,
extension.as_ptr(),
verb.as_ptr(),
buf.as_mut_ptr(),
&mut len,
)
};
if filled < 0 {
return None;
}
let end = buf.iter().position(|&u| u == 0).unwrap_or(buf.len());
let command = String::from_utf16_lossy(&buf[..end]);
(!command.trim().is_empty()).then_some(command)
}
/// Where a Windows plot goes and how. The route is decided before any
/// system call so the decision itself can be tested anywhere.
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
pub(crate) enum PrintRoute {
/// Hand the PDF to the registered PDF application's `printto` verb,
/// naming the printer explicitly.
PdfApplication { printer: String },
/// Rasterise in-process and draw to the printer through the spooler.
Direct { printer: String },
}
impl PrintRoute {
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
pub(crate) fn printer(&self) -> &str {
match self {
PrintRoute::PdfApplication { printer } | PrintRoute::Direct { printer } => printer,
}
}
}
/// Resolve the dialog's choice to a route. "Default" (no name) becomes the
/// system default printer *by name*, so the job can never drift to whatever
/// the PDF application considers its default; the PDF application is used
/// only when it registered a `printto` verb.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
pub(crate) fn plan_print_route(
requested: Option<&str>,
default_printer: Option<&str>,
printto_registered: bool,
) -> Result<PrintRoute, String> {
let requested = requested.map(str::trim).filter(|name| !name.is_empty());
let printer = match requested.or(default_printer.map(str::trim).filter(|n| !n.is_empty())) {
Some(name) => name.to_string(),
None => return Err("No default printer is configured on Windows.".into()),
};
Ok(if printto_registered {
PrintRoute::PdfApplication { printer }
} else {
PrintRoute::Direct { printer }
})
}
#[cfg(test)]
mod print_route_tests {
use super::{plan_print_route, PrintRoute};
#[test]
fn a_named_printer_is_kept_and_blank_means_the_default() {
assert_eq!(
plan_print_route(Some("Office Laser"), Some("Home Inkjet"), false),
Ok(PrintRoute::Direct {
printer: "Office Laser".into()
})
);
assert_eq!(
plan_print_route(Some(" "), Some("Home Inkjet"), false),
Ok(PrintRoute::Direct {
printer: "Home Inkjet".into()
})
);
assert_eq!(
plan_print_route(None, Some(" Home Inkjet "), true),
Ok(PrintRoute::PdfApplication {
printer: "Home Inkjet".into()
})
);
}
#[test]
fn no_printer_at_all_is_an_error_not_a_guess() {
assert!(plan_print_route(None, None, true).is_err());
assert!(plan_print_route(Some(""), Some(""), false).is_err());
}
#[test]
fn the_pdf_application_is_used_only_with_a_printto_verb() {
let direct = plan_print_route(Some("X"), None, false).unwrap();
let via_app = plan_print_route(Some("X"), None, true).unwrap();
assert!(matches!(direct, PrintRoute::Direct { .. }));
assert!(matches!(via_app, PrintRoute::PdfApplication { .. }));
assert_eq!(direct.printer(), "X");
}
}
#[cfg(target_os = "windows")]
fn windows_printers() -> std::io::Result<Vec<String>> {
use windows_sys::Win32::Graphics::Printing::{
PRINTER_ENUM_CONNECTIONS, PRINTER_ENUM_LOCAL, PRINTER_ENUM_NETWORK,
};
let mut names = enumerate_printers(PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS)?;
// LOCAL|CONNECTIONS misses network-discovered printers (spooler-shared and
// per-machine connections), which is what "the plot dialog lost printers"
// reports look like. A second NETWORK-augmented pass can only widen the
// list: when the spooler rejects the extra flag it just yields nothing.
if let Ok(extra) =
enumerate_printers(PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS | PRINTER_ENUM_NETWORK)
{
names.extend(extra);
names.sort();
names.dedup();
}
Ok(names)
}
#[cfg(target_os = "windows")]
fn enumerate_printers(flags: u32) -> std::io::Result<Vec<String>> {
use windows_sys::Win32::Foundation::{GetLastError, ERROR_INSUFFICIENT_BUFFER};
use windows_sys::Win32::Graphics::Printing::EnumPrintersW;
let mut bytes_needed = 0;
let mut printer_count = 0;
let mut buffer = Vec::<usize>::new();
// The printer list can grow between the size query and the data query.
for _ in 0..4 {
let buffer_bytes = u32::try_from(std::mem::size_of_val(buffer.as_slice()))
.map_err(|_| std::io::Error::from(std::io::ErrorKind::OutOfMemory))?;
let data = if buffer.is_empty() {
std::ptr::null_mut()
} else {
buffer.as_mut_ptr().cast::<u8>()
};
// SAFETY: data is null for the size query, otherwise it points to an
// aligned, writable allocation of buffer_bytes bytes.
let success = unsafe {
EnumPrintersW(
flags,
std::ptr::null(),
4,
data,
buffer_bytes,
&mut bytes_needed,
&mut printer_count,
)
};
if success != 0 {
return windows_printer_names(&buffer, printer_count as usize);
}
let error = unsafe { GetLastError() };
if error != ERROR_INSUFFICIENT_BUFFER || bytes_needed <= buffer_bytes {
return Err(std::io::Error::from_raw_os_error(error as i32));
}
buffer.resize(
(bytes_needed as usize).div_ceil(std::mem::size_of::<usize>()),
0,
);
}
Err(std::io::Error::from_raw_os_error(
ERROR_INSUFFICIENT_BUFFER as i32,
))
}
#[cfg(target_os = "windows")]
fn windows_printer_names(buffer: &[usize], printer_count: 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>());
}
let buffer_bytes = std::mem::size_of_val(buffer);
if printer_count > buffer_bytes / std::mem::size_of::<PRINTER_INFO_4W>() {
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 mut names = Vec::with_capacity(printer_count);
for record in records {
if record.pPrinterName.is_null() {
continue;
}
let offset = (record.pPrinterName as usize)
.checked_sub(buffer.as_ptr() as usize)
.filter(|offset| *offset < buffer_bytes && offset % 2 == 0)
.ok_or(Error::from(ErrorKind::InvalidData))?;
// SAFETY: offset is UTF-16 aligned and the slice ends within buffer.
let utf16 = unsafe {
std::slice::from_raw_parts(
buffer.as_ptr().cast::<u8>().add(offset).cast::<u16>(),
(buffer_bytes - offset) / 2,
)
};
let length = utf16
.iter()
.position(|&unit| unit == 0)
.ok_or(Error::from(ErrorKind::InvalidData))?;
if length != 0 {
names.push(String::from_utf16_lossy(&utf16[..length]));
}
}
names.sort();
names.dedup();
Ok(names)
}
#[cfg(all(test, target_os = "windows"))]
mod printer_buffer_tests {
use super::windows_printer_names;
use windows_sys::Win32::Graphics::Printing::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());
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();
// SAFETY: the aligned allocation holds the record and UTF-16 name.
unsafe {
let record = buffer.as_mut_ptr().cast::<PRINTER_INFO_4W>();
let name_ptr = buffer
.as_mut_ptr()
.cast::<u8>()
.add(name_offset)
.cast::<u16>();
std::ptr::copy_nonoverlapping(name.as_ptr(), name_ptr, name.len());
(*record).pPrinterName = name_ptr;
assert_eq!(
windows_printer_names(&buffer, 1).unwrap(),
["Printer \u{03b1}"]
);
(*record).pPrinterName = buffer.as_mut_ptr().cast::<u16>().wrapping_sub(1);
assert!(windows_printer_names(&buffer, 1).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());
}
}
/// Build the platform printer-properties command.
#[cfg(not(target_arch = "wasm32"))]
fn printer_properties_command(printer: Option<&str>) -> (&'static str, Vec<String>) {
let named = printer.map(str::trim).filter(|name| !name.is_empty());
// `/e` opens the driver's *printing preferences* (paper, tray, duplex,
// quality — what a print job honours), not the device's admin properties
// sheet that `/p` shows.
#[cfg(target_os = "windows")]
let command = match named {
Some(name) => (
"rundll32.exe",
vec![
"printui.dll,PrintUIEntry".to_string(),
"/e".to_string(),
"/n".to_string(),
name.to_string(),
],
),
None => ("control.exe", vec!["printers".to_string()]),
};
#[cfg(target_os = "macos")]
let command = {
let _ = named;
(
"open",
vec!["x-apple.systempreferences:com.apple.Print-Scan-Settings.extension".to_string()],
)
};
#[cfg(all(not(target_os = "windows"), not(target_os = "macos")))]
let command = {
// Reached only outside Linux; Linux opens the desktop's own panel.
let target = named
.map(|name| format!("http://localhost:631/printers/{name}"))
.unwrap_or_else(|| "http://localhost:631/printers".to_string());
("xdg-open", vec![target])
};
command
}
/// Open the operating system's printer configuration surface.
#[cfg(not(target_arch = "wasm32"))]
pub fn open_printer_properties(printer: Option<&str>) -> Result<(), String> {
#[cfg(target_os = "linux")]
{
return open_desktop_printer_settings(printer);
}
#[allow(unreachable_code)]
let (program, args) = printer_properties_command(printer);
std::process::Command::new(program)
.args(args)
.spawn()
.map(|_| ())
.map_err(|error| format!("Could not open printer properties: {error}"))
}
#[cfg(target_arch = "wasm32")]
pub fn open_printer_properties(_printer: Option<&str>) -> Result<(), String> {
Err("Printer properties are not available in the web version.".into())
}
/// Render a page and send it to the selected printer.
#[cfg(not(target_arch = "wasm32"))]
pub async fn print_wires_with(
page: crate::io::pdf_export::PdfPageInput,
opts: PrintOptions,
) -> Result<String, String> {
let tmp_path = temp_pdf_path("print");
pdf_export::export_pdf(&page, &tmp_path)?;
dispatch_to_printer_opts(&tmp_path, &opts)
}
/// Send an already-rendered PDF to the selected printer.
#[cfg(not(target_arch = "wasm32"))]
pub fn print_existing_pdf(path: &std::path::Path, opts: &PrintOptions) -> Result<String, String> {
dispatch_to_printer_opts(path, opts)
}
/// Raster resolution for the GDI fallback print. 300 DPI balances spool
/// memory (a 297mm-wide page is ~3.5k pixels) against readable linework; the
/// on-screen rasteriser runs at 150 DPI, which looks soft on paper.
#[cfg(target_os = "windows")]
const GDI_PRINT_DPI: f32 = 300.0;
/// Print by rasterising the PDF in-process (hayro) and drawing the bitmap to
/// the printer DC with GDI. Needs no PDF application — the ShellExecute verb
/// path only works when the registered PDF viewer exposes `print`/`printto`,
/// and many (Edge, store readers like ASC.Pdf) register only `open`.
/// Rotate a top-down RGBA raster 90° clockwise (width and height swap).
#[cfg(target_os = "windows")]
fn rotate_rgba90(pixels: &[u8], width: u32, height: u32) -> Vec<u8> {
let mut rotated = vec![0u8; pixels.len()];
for y in 0..height as usize {
for x in 0..width as usize {
let src = (y * width as usize + x) * 4;
// Clockwise: destination column = height-1-y, row = x.
let dst = (x * height as usize + (height as usize - 1 - y)) * 4;
rotated[dst..dst + 4].copy_from_slice(&pixels[src..src + 4]);
}
}
rotated
}
/// Open `device`, fetch the driver's default DEVMODE (public struct plus the
/// driver's private data behind it) and let `edit` change it while the
/// printer is still open — a change has to be reconciled by the driver
/// through `DocumentPropertiesW` with that handle. `edit` returns `false`
/// to reject the buffer. `None` when the driver could not be queried.
#[cfg(target_os = "windows")]
fn with_printer_devmode(
device_wide: &[u16],
edit: impl FnOnce(windows_sys::Win32::Graphics::Printing::PRINTER_HANDLE, &mut Vec<u8>) -> bool,
) -> Option<Vec<u8>> {
use windows_sys::Win32::Graphics::Gdi::{DEVMODEW, DM_OUT_BUFFER};
use windows_sys::Win32::Graphics::Printing::{
ClosePrinter, DocumentPropertiesW, OpenPrinterW, PRINTER_HANDLE,
};
unsafe {
let mut handle = PRINTER_HANDLE::default();
if OpenPrinterW(device_wide.as_ptr(), &mut handle, std::ptr::null()) == 0 {
return None;
}
let result = (|| {
// The size query is the one call that takes no mode flags at all:
// with DM_OUT_BUFFER and no output buffer to write into, the
// driver reports failure (-1) instead of the size, and every
// caller below silently lost the driver's DEVMODE.
let needed = DocumentPropertiesW(
std::ptr::null_mut(),
handle,
device_wide.as_ptr(),
std::ptr::null_mut(),
std::ptr::null(),
0,
);
if needed <= 0 {
return None;
}
let mut buf = vec![0u8; needed as usize];
let dm = buf.as_mut_ptr() as *mut DEVMODEW;
if DocumentPropertiesW(
std::ptr::null_mut(),
handle,
device_wide.as_ptr(),
dm,
std::ptr::null(),
DM_OUT_BUFFER,
) <= 0
{
return None;
}
// The driver's own dmSize locates its private data
// (dmDriverExtra) behind the public struct; only a driver that
// left it zero gets the public size.
if (*dm).dmSize == 0 {
(*dm).dmSize = std::mem::size_of::<DEVMODEW>() as u16;
}
edit(handle, &mut buf).then_some(buf)
})();
ClosePrinter(handle);
result
}
}
/// The sheet a print job asks the driver for: one of the driver's own
/// sheets by id, or a user-defined size in tenths of a millimetre.
#[cfg(target_os = "windows")]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum DevmodeSheet {
Id(u16),
UserMm {
width_tenths: i16,
length_tenths: i16,
},
}
/// Fetch the printer driver's DEVMODE, flip its orientation to match the
/// plot's aspect (`landscape` = image wider than tall) and, when a sheet is
/// given, select it. The merged DEVMODE is returned as raw bytes (drivers
/// append private data after the struct). A driver that rejects the sheet
/// is asked again for the orientation alone; `None` when the driver could
/// not be queried — CreateDC then falls back to the driver default.
#[cfg(target_os = "windows")]
fn oriented_printer_devmode(
device_wide: &[u16],
landscape: bool,
sheet: Option<DevmodeSheet>,
) -> Option<Vec<u8>> {
use windows_sys::Win32::Graphics::Gdi::{
DEVMODEW, DMORIENT_LANDSCAPE, DMORIENT_PORTRAIT, DM_IN_BUFFER, DM_ORIENTATION,
DM_OUT_BUFFER, DM_PAPERLENGTH, DM_PAPERSIZE, DM_PAPERWIDTH,
};
use windows_sys::Win32::Graphics::Printing::DocumentPropertiesW;
let attempt = |sheet: Option<DevmodeSheet>| {
with_printer_devmode(device_wide, |handle, buf| unsafe {
let dm = buf.as_mut_ptr() as *mut DEVMODEW;
(*dm).dmFields |= DM_ORIENTATION;
(*dm).Anonymous1.Anonymous1.dmOrientation = if landscape {
DMORIENT_LANDSCAPE as i16
} else {
DMORIENT_PORTRAIT as i16
};
match sheet {
Some(DevmodeSheet::Id(id)) => {
(*dm).dmFields |= DM_PAPERSIZE;
(*dm).dmFields &= !(DM_PAPERWIDTH | DM_PAPERLENGTH);
(*dm).Anonymous1.Anonymous1.dmPaperSize = id as i16;
}
Some(DevmodeSheet::UserMm {
width_tenths,
length_tenths,
}) => {
(*dm).dmFields |= DM_PAPERSIZE | DM_PAPERWIDTH | DM_PAPERLENGTH;
(*dm).Anonymous1.Anonymous1.dmPaperSize =
crate::io::windows_media::DMPAPER_USER as i16;
(*dm).Anonymous1.Anonymous1.dmPaperWidth = width_tenths;
(*dm).Anonymous1.Anonymous1.dmPaperLength = length_tenths;
}
None => {}
}
// Let the driver reconcile fields that depend on orientation and
// sheet (paper dimensions, imageable area) before we print with it.
DocumentPropertiesW(
std::ptr::null_mut(),
handle,
device_wide.as_ptr(),
dm,
dm,
DM_IN_BUFFER | DM_OUT_BUFFER,
) > 0
})
};
match sheet {
Some(sheet) => attempt(Some(sheet)).or_else(|| attempt(None)),
None => attempt(None),
}
}
/// The DEVMODE sheet for a plot of `width_mm` × `height_mm`: the driver's
/// own id when it lists that size, else a user-defined size (which the
/// driver may still refuse — the caller then prints on the default sheet).
#[cfg(target_os = "windows")]
fn devmode_sheet_for(printer: &str, width_mm: f64, height_mm: f64) -> Option<DevmodeSheet> {
let tenths = |mm: f64| -> Option<i16> { i16::try_from((mm * 10.0).round() as i64).ok() };
match windows_printer_media(printer) {
Some(raw) => {
let listed = crate::io::windows_media::paper_id_for_sheet(&raw, width_mm, height_mm);
if let Some(id) = listed {
return Some(DevmodeSheet::Id(id));
}
// A driver that lists sheets but not this one gets it as a
// user-defined size, portrait (the orientation flag turns it).
let (w, h) = if width_mm <= height_mm {
(width_mm, height_mm)
} else {
(height_mm, width_mm)
};
Some(DevmodeSheet::UserMm {
width_tenths: tenths(w)?,
length_tenths: tenths(h)?,
})
}
None => None,
}
}
/// What the driver of `printer` says about its sheets: the three parallel
/// `DeviceCapabilities` tables, the default DEVMODE's sheet and the margins
/// of a portrait printer DC. `None` when the driver lists no sheet (a fax or
/// virtual queue) or cannot be asked at all — the dialog keeps the catalogue.
/// Slow for an offline network queue (the spooler may fetch the driver), so
/// it is only ever called from a background task.
#[cfg(target_os = "windows")]
pub(crate) fn windows_printer_media(
printer: &str,
) -> Option<crate::io::windows_media::RawPrinterMedia> {
use crate::io::windows_media::{DeviceCapsSample, RawPrinterMedia, PAPER_NAME_CHARS};
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Foundation::POINT;
use windows_sys::Win32::Graphics::Gdi::{
CreateDCW, DeleteDC, GetDeviceCaps, DEVMODEW, DM_PAPERLENGTH, DM_PAPERSIZE, DM_PAPERWIDTH,
HORZRES, LOGPIXELSX, LOGPIXELSY, PHYSICALHEIGHT, PHYSICALOFFSETX, PHYSICALOFFSETY,
PHYSICALWIDTH, VERTRES,
};
use windows_sys::Win32::Storage::Xps::{
DeviceCapabilitiesW, DC_PAPERNAMES, DC_PAPERS, DC_PAPERSIZE,
};
let wide = |s: &str| -> Vec<u16> {
std::ffi::OsStr::new(s)
.encode_wide()
.chain(Some(0))
.collect()
};
let device_wide = wide(printer.trim());
// SAFETY: the device name is NUL-terminated; a null output buffer asks
// for the count only.
let count = |capability: u16| -> Option<usize> {
let n = unsafe {
DeviceCapabilitiesW(
device_wide.as_ptr(),
std::ptr::null(),
capability,
std::ptr::null_mut(),
std::ptr::null(),
)
};
(n > 0).then_some(n as usize)
};
// The three tables are parallel; a driver that disagrees on their
// lengths is read up to the shortest.
let count = count(DC_PAPERS)?
.min(count(DC_PAPERSIZE)?)
.min(count(DC_PAPERNAMES)?);
let mut ids = vec![0u16; count];
let mut sizes = vec![POINT { x: 0, y: 0 }; count];
let mut names = vec![0u16; count * PAPER_NAME_CHARS];
// SAFETY: each buffer holds `count` entries of the type the capability
// writes (WORD, POINT, 64 WCHARs); the calls fill at most that many.
let filled = unsafe {
DeviceCapabilitiesW(
device_wide.as_ptr(),
std::ptr::null(),
DC_PAPERS,
ids.as_mut_ptr(),
std::ptr::null(),
) > 0
&& DeviceCapabilitiesW(
device_wide.as_ptr(),
std::ptr::null(),
DC_PAPERSIZE,
sizes.as_mut_ptr().cast::<u16>(),
std::ptr::null(),
) > 0
&& DeviceCapabilitiesW(
device_wide.as_ptr(),
std::ptr::null(),
DC_PAPERNAMES,
names.as_mut_ptr(),
std::ptr::null(),
) > 0
};
if !filled {
return None;
}
// The driver's default sheet, from its default DEVMODE — which also
// makes the margin DC start portrait and on that sheet.
let devmode = with_printer_devmode(&device_wide, |_, _| true);
let (default_id, default_tenths_mm) = devmode
.as_ref()
.map(|buf| {
// SAFETY: the buffer holds at least a public DEVMODEW.
let dm = unsafe { &*(buf.as_ptr() as *const DEVMODEW) };
let fields = dm.dmFields;
let paper = unsafe { dm.Anonymous1.Anonymous1 };
let id = (fields & DM_PAPERSIZE != 0).then_some(paper.dmPaperSize as u16);
let size = (fields & DM_PAPERWIDTH != 0 && fields & DM_PAPERLENGTH != 0).then_some((
i32::from(paper.dmPaperWidth),
i32::from(paper.dmPaperLength),
));
(id, size)
})
.unwrap_or((None, None));
let winspool = wide("WINSPOOL");
let init = devmode
.as_ref()
.map(|b| b.as_ptr().cast::<DEVMODEW>())
.unwrap_or(std::ptr::null());
// SAFETY: the strings are NUL-terminated and `devmode` outlives the DC.
let hdc = unsafe {
CreateDCW(
winspool.as_ptr(),
device_wide.as_ptr(),
std::ptr::null(),
init,
)
};
let margins = if hdc.is_null() {
None
} else {
// SAFETY: hdc is a valid printer DC; each index is documented.
let caps = |index: u32| unsafe { GetDeviceCaps(hdc, index as i32) };
let sample = DeviceCapsSample {
physical_width: caps(PHYSICALWIDTH),
physical_height: caps(PHYSICALHEIGHT),
offset_x: caps(PHYSICALOFFSETX),
offset_y: caps(PHYSICALOFFSETY),
horz_res: caps(HORZRES),
vert_res: caps(VERTRES),
dpi_x: caps(LOGPIXELSX),
dpi_y: caps(LOGPIXELSY),
};
unsafe { DeleteDC(hdc) };
crate::io::windows_media::device_margins_mm(&sample)
};
Some(RawPrinterMedia {
ids,
sizes_tenths_mm: sizes.into_iter().map(|p| (p.x, p.y)).collect(),
names_utf16: names,
default_id,
default_tenths_mm,
margins,
})
}
#[cfg(target_os = "windows")]
fn gdi_raster_print(
path: &std::path::Path,
printer: &str,
copies: u32,
output_file: Option<&std::path::Path>,
) -> Result<(), String> {
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Graphics::Gdi::{
CreateDCW, DeleteDC, GetDeviceCaps, SetStretchBltMode, StretchDIBits, BITMAPINFO,
BITMAPINFOHEADER, BI_RGB, COLORONCOLOR, DEVMODEW, DIB_RGB_COLORS, HORZRES, SRCCOPY,
VERTRES,
};
// windows-sys 0.61 groups the spooler-document calls (StartDoc/StartPage/
// EndPage/EndDoc/DOCINFOW) under Storage::Xps, not Graphics::Gdi.
use windows_sys::Win32::Storage::Xps::{EndDoc, EndPage, StartDocW, StartPage, DOCINFOW};
let wide = |s: &str| -> Vec<u16> {
std::ffi::OsStr::new(s)
.encode_wide()
.chain(Some(0))
.collect()
};
// The device is resolved by the caller (`plan_print_route`): a name the
// dialog chose or the system default looked up by name.
let device = printer.trim();
if device.is_empty() {
return Err("No printer was chosen for direct printing.".into());
}
let device = device.to_string();
// A plot is printed once; the on-screen memo of PDF pages must not keep
// a 300 DPI raster of every job for the rest of the session.
let page = crate::scene::model::pdf_raster::rasterize_page_at_dpi_uncached(
&path.to_string_lossy(),
"1",
GDI_PRINT_DPI,
)
.ok_or_else(|| "Could not rasterise the plot for direct printing.".to_string())?;
let winspool = wide("WINSPOOL");
let device_wide = wide(&device);
// The dialog's sheet and Landscape/Portrait choice ride in through the
// DEVMODE: the raster's size decides both, and the driver's default must
// not override them (a landscape A3 plot into the driver's portrait A4
// used to shrink and misorient the output).
let sheet_w_mm = f64::from(page.width) / f64::from(GDI_PRINT_DPI) * 25.4;
let sheet_h_mm = f64::from(page.height) / f64::from(GDI_PRINT_DPI) * 25.4;
let sheet = devmode_sheet_for(&device, sheet_w_mm, sheet_h_mm);
let dm_buf = oriented_printer_devmode(&device_wide, page.width > page.height, sheet);
let init = dm_buf
.as_ref()
.map(|b| b.as_ptr().cast::<DEVMODEW>())
.unwrap_or(std::ptr::null());
// SAFETY: all pointers are NUL-terminated / valid for the call; the DC is
// released below and dm_buf outlives it.
let hdc = unsafe {
CreateDCW(
winspool.as_ptr(),
device_wide.as_ptr(),
std::ptr::null(),
init,
)
};
if hdc.is_null() {
return Err(format!("Could not open printer \"{device}\" for printing."));
}
let result = (|| -> Result<(), String> {
// SAFETY: hdc is a valid printer DC; each capability is a documented index.
let (page_w, page_h) = unsafe {
(
GetDeviceCaps(hdc, HORZRES as i32),
GetDeviceCaps(hdc, VERTRES as i32),
)
};
if page_w <= 0 || page_h <= 0 {
return Err("Printer reported no printable area.".into());
}
// BGRA, top-down — what StretchDIBits expects for BI_RGB 32bpp.
let mut bgra = page.pixels.as_ref().clone();
for px in bgra.chunks_exact_mut(4) {
px.swap(0, 2);
}
let mut src_w = page.width;
let mut src_h = page.height;
// Drivers whose DEVMODE could not be set (v4 class drivers may fail
// the DocumentProperties query) keep their default orientation. When
// that mismatches the plot sheet, rotating the raster 90° keeps the
// plot at full size on the page instead of shrinking it to fit.
if (page_w > page_h) != (src_w > src_h) {
bgra = rotate_rgba90(&bgra, src_w, src_h);
std::mem::swap(&mut src_w, &mut src_h);
}
// Fit inside the printable area, preserving the page's aspect ratio.
let fit_w = f64::from(page_w) / f64::from(src_w);
let fit_h = f64::from(page_h) / f64::from(src_h);
let scale = fit_w.min(fit_h);
let draw_w = (f64::from(src_w) * scale).max(1.0) as i32;
let draw_h = (f64::from(src_h) * scale).max(1.0) as i32;
let x = (page_w - draw_w) / 2;
let y = (page_h - draw_h) / 2;
let doc_name = wide("OpenCADStudio Plot");
// An optional redirect writes the spooled output to a file instead of
// the physical tray — how "Microsoft Print to PDF" prints silently,
// and how the fallback proves itself in tests without wasting paper.
let redirect = output_file.map(|p| wide(&p.to_string_lossy()));
let info = DOCINFOW {
cbSize: std::mem::size_of::<DOCINFOW>() as i32,
lpszDocName: doc_name.as_ptr(),
lpszOutput: redirect
.as_ref()
.map(|w| w.as_ptr())
.unwrap_or(std::ptr::null()),
lpszDatatype: std::ptr::null(),
fwType: 0,
};
// SAFETY: info and its strings outlive every StartDoc/EndDoc call here.
if unsafe { StartDocW(hdc, &info) } <= 0 {
return Err(format!("Print job could not start (code {}).", unsafe {
windows_sys::Win32::Foundation::GetLastError()
}));
}
unsafe { SetStretchBltMode(hdc, COLORONCOLOR) };
let mut bmi = BITMAPINFO {
bmiHeader: BITMAPINFOHEADER {
biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
biWidth: src_w as i32,
// Negative height: top-down rows, no vertical flip needed.
biHeight: -(src_h as i32),
biPlanes: 1,
biBitCount: 32,
biCompression: BI_RGB,
biSizeImage: bgra.len() as u32,
..Default::default()
},
bmiColors: [Default::default()],
};
for _ in 0..copies.max(1) {
if unsafe { StartPage(hdc) } <= 0 {
return Err(format!("Could not start a print page (code {}).", unsafe {
windows_sys::Win32::Foundation::GetLastError()
}));
}
// StartPage resets DC attributes, so the stretch mode is set per page.
unsafe { SetStretchBltMode(hdc, COLORONCOLOR) };
let drawn = unsafe {
StretchDIBits(
hdc,
x,
y,
draw_w,
draw_h,
0,
0,
src_w as i32,
src_h as i32,
bgra.as_ptr().cast(),
&mut bmi,
DIB_RGB_COLORS,
SRCCOPY,
)
};
// Failure returns GDI_ERROR (-1); a successful draw returns the
// source height it copied (>= 0, 0 only for zero-height pages).
if drawn < 0 {
return Err(format!(
"Drawing to the printer failed (code {}).",
unsafe { windows_sys::Win32::Foundation::GetLastError() }
));
}
if unsafe { EndPage(hdc) } <= 0 {
return Err(format!(
"Could not finish a print page (code {}).",
unsafe { windows_sys::Win32::Foundation::GetLastError() }
));
}
}
if unsafe { EndDoc(hdc) } <= 0 {
return Err(format!("Print job could not complete (code {}).", unsafe {
windows_sys::Win32::Foundation::GetLastError()
}));
}
Ok(())
})();
// SAFETY: the DC was created above and used only on this thread.
unsafe { DeleteDC(hdc) };
result
}
/// Open a file with the OS default application (used for print preview).
#[cfg(all(not(target_arch = "wasm32"), test))]
pub fn open_in_viewer(path: &std::path::Path) -> Result<(), String> {
// Unit tests exercise the complete preview pipeline, but must not launch
// the user's PDF application or leave generated previews in the temp
// directory. Production viewers need the file after this call returns;
// the test path has no external consumer, so it can be removed now.
std::fs::remove_file(path).map_err(|error| format!("Could not consume test preview: {error}"))
}
/// Open a file with the OS default application (used for print preview).
#[cfg(all(not(target_arch = "wasm32"), not(test)))]
pub fn open_in_viewer(path: &std::path::Path) -> Result<(), String> {
let p = path.to_string_lossy().to_string();
#[cfg(target_os = "windows")]
let mut cmd = {
let mut c = std::process::Command::new("cmd");
c.args(["/C", "start", "", &p]);
c
};
#[cfg(target_os = "macos")]
let mut cmd = {
let mut c = std::process::Command::new("open");
c.arg(&p);
c
};
#[cfg(all(not(target_os = "windows"), not(target_os = "macos")))]
let mut cmd = {
let mut c = std::process::Command::new("xdg-open");
c.arg(&p);
c
};
cmd.spawn()
.map(|_| ())
.map_err(|e| format!("Could not open preview: {e}"))
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod preview_isolation_tests {
#[test]
fn a_test_preview_is_consumed_without_leaving_a_temp_file() {
let path = super::temp_pdf_path("preview_test");
std::fs::write(&path, b"test preview").unwrap();
super::open_in_viewer(&path).unwrap();
assert!(
!path.exists(),
"test preview was not cleaned up: {}",
path.display()
);
}
}
/// Ask the registered PDF application to print `path` on `printer` through
/// its `printto` verb, once per copy. The printer name is quoted: some
/// `printto` command templates do not quote their `%2`.
#[cfg(target_os = "windows")]
fn shell_print_to(path: &std::path::Path, printer: &str, copies: u32) -> Result<(), String> {
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Foundation::{GetLastError, ERROR_NO_ASSOCIATION};
use windows_sys::Win32::UI::Shell::{
ShellExecuteExW, SEE_MASK_FLAG_NO_UI, SEE_MASK_NOASYNC, SE_ERR_NOASSOC, SHELLEXECUTEINFOW,
};
use windows_sys::Win32::UI::WindowsAndMessaging::SW_HIDE;
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 = wide("printto");
let params = wide(&format!("\"{printer}\""));
for _ in 0..copies.max(1) {
let mut info = SHELLEXECUTEINFOW {
cbSize: std::mem::size_of::<SHELLEXECUTEINFOW>() as u32,
fMask: SEE_MASK_FLAG_NO_UI | SEE_MASK_NOASYNC,
lpVerb: verb.as_ptr(),
lpFile: path_wide.as_ptr(),
lpParameters: params.as_ptr(),
nShow: SW_HIDE,
..Default::default()
};
// SAFETY: every pointer in `info` outlives the call.
if unsafe { ShellExecuteExW(&mut info) } == 0 {
let shell_code = info.hInstApp as usize;
let code = if (1..=32).contains(&shell_code) {
shell_code as u32
} else {
unsafe { GetLastError() }
};
if code == SE_ERR_NOASSOC || code == ERROR_NO_ASSOCIATION {
return Err("Windows has no PDF application registered with Print support.".into());
}
return Err(format!("Windows print dispatch failed (code {code})"));
}
}
Ok(())
}
/// Render a page and show it the way each platform previews: the OS PDF
/// viewer on desktop, an in-page rasterised overlay on the web. `stem` names
/// the web download (`{stem}_preview.pdf`); the desktop temp file is unchanged.
pub fn preview_page_pdf(
stem: &str,
page: &crate::io::pdf_export::PdfPageInput,
) -> Result<(), String> {
#[cfg(not(target_arch = "wasm32"))]
{
let _ = stem;
let tmp = temp_pdf_path("preview");
crate::io::pdf_export::export_pdf(page, &tmp)?;
open_in_viewer(&tmp)
}
#[cfg(target_arch = "wasm32")]
{
let bytes = crate::io::pdf_export::render_pdf_bytes(std::slice::from_ref(page), None)?;
web_preview_overlay(stem, &bytes)
}
}
/// Web-only multi-page variant used by print-all: render every layout into
/// one PDF and preview the first pages in the overlay.
#[cfg(target_arch = "wasm32")]
pub fn preview_pages_pdf(
stem: &str,
pages: &[crate::io::pdf_export::PdfPageInput],
) -> Result<(), String> {
let bytes = crate::io::pdf_export::render_pdf_bytes(pages, None)?;
web_preview_overlay(stem, &bytes)
}
/// Web preview: rasterise the plotted PDF (the same hayro pipeline that draws
/// PDF underlays) and show the pages in an in-page overlay. The browser gets
/// plain PNGs, so neither popup permission nor a PDF viewer plugin is needed.
/// Rasterisation is bounded by a total pixel budget so large sheets lower the
/// preview DPI instead of the tab's memory ceiling.
#[cfg(target_arch = "wasm32")]
fn web_preview_overlay(stem: &str, bytes: &[u8]) -> Result<(), String> {
use crate::scene::model::pdf_raster;
const PREVIEW_DPI: f32 = 150.0;
const MAX_PREVIEW_PAGES: usize = 12;
let (pages, total_pages) = pdf_raster::rasterize_preview_pages(
std::sync::Arc::new(bytes.to_vec()),
PREVIEW_DPI,
MAX_PREVIEW_PAGES,
);
let mut pngs = Vec::with_capacity(pages.len());
for page in &pages {
let encoded =
image::RgbaImage::from_raw(page.width, page.height, page.pixels.as_ref().clone())
.and_then(|img| {
let mut out = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
.ok()?;
Some(out)
});
if let Some(png) = encoded {
pngs.push(png);
}
}
crate::sys::show_pdf_preview(&format!("{stem}_preview.pdf"), bytes, &pngs, total_pages);
Ok(())
}
/// Dispatch a PDF to a specific printer with [`PrintOptions`].
#[cfg(not(target_arch = "wasm32"))]
fn dispatch_to_printer_opts(path: &std::path::Path, opts: &PrintOptions) -> Result<String, String> {
#[cfg(target_os = "windows")]
{
let default_printer = windows_default_printer().ok();
let route = plan_print_route(
opts.printer.as_deref(),
default_printer.as_deref(),
pdf_printto_command().is_some(),
)?;
let printer = route.printer().to_string();
let via_application = match &route {
PrintRoute::PdfApplication { .. } => {
match shell_print_to(path, &printer, opts.copies) {
Ok(()) => true,
// The PDF application refused the job: the spooler route
// still prints it, and the report says so.
Err(_) => {
gdi_raster_print(path, &printer, opts.copies, None)?;
false
}
}
}
PrintRoute::Direct { .. } => {
gdi_raster_print(path, &printer, opts.copies, None)?;
false
}
};
// A direct job is fully spooled when the calls return; the temporary
// PDF behind it has no reader left. The PDF application reads its
// file after ShellExecute returns, so that copy is left to the
// temp folder.
if !via_application && path.starts_with(std::env::temp_dir()) {
let _ = std::fs::remove_file(path);
}
let how = if via_application {
crate::t!("via the PDF application")
} else {
crate::t!("direct print")
};
Ok(format!("{printer} ({how})"))
}
#[cfg(not(target_os = "windows"))]
{
let path_str = path.to_string_lossy();
let mut cmd = std::process::Command::new("lp");
if let Some(p) = opts.printer.as_deref() {
if !p.is_empty() {
cmd.arg("-d").arg(p);
}
}
let copies = opts.copies.max(1);
if copies > 1 {
cmd.arg("-n").arg(copies.to_string());
}
if let Some(q) = opts.quality.as_deref() {
// CUPS print-quality: 3 = draft, 4 = normal, 5 = high / best.
let pq = match q {
"Low" => "3",
"High" => "5",
_ => "4",
};
cmd.arg("-o").arg(format!("print-quality={pq}"));
}
// The driver's own options, exactly as the PPD names them.
for (key, value) in &opts.driver_options {
cmd.arg("-o").arg(format!("{key}={value}"));
}
let lp_result = cmd.arg("--").arg(path_str.as_ref()).output();
if let Ok(out) = &lp_result {
if !out.status.success() {
// Continue to the lpr fallback below.
} else {
let msg = String::from_utf8_lossy(&out.stdout);
let printer = msg
.split_whitespace()
.find(|w| w.contains('-'))
.unwrap_or("printer")
.to_string();
return Ok(printer);
}
}
let mut fallback = std::process::Command::new("lpr");
if let Some(printer) = opts.printer.as_deref().filter(|name| !name.is_empty()) {
fallback.arg("-P").arg(printer);
}
if copies > 1 {
fallback.arg(format!("-#{copies}"));
}
let out = fallback
.arg(path_str.as_ref())
.output()
.map_err(|error| match lp_result {
Ok(ref lp) => format!(
"lp failed: {}; lpr could not launch: {error}",
String::from_utf8_lossy(&lp.stderr)
),
Err(ref lp) => format!("lp could not launch: {lp}; lpr could not launch: {error}"),
})?;
if out.status.success() {
Ok(opts
.printer
.clone()
.unwrap_or_else(|| "default printer".into()))
} else {
Err(format!(
"lpr failed: {}",
String::from_utf8_lossy(&out.stderr)
))
}
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod printer_properties_tests {
use super::printer_properties_command;
/// The GDI fallback rasterises the plot PDF in-process; prove the 300 DPI
/// path yields a page sized for print rather than the screen's 150 DPI.
#[cfg(target_os = "windows")]
#[test]
fn gdi_fallback_rasterises_the_plot_pdf_at_print_dpi() {
use printpdf::{Mm, PdfDocument, PdfPage as OutputPage, PdfSaveOptions};
let mut document = PdfDocument::new("gdi print test");
document
.pages
.push(OutputPage::new(Mm(210.0), Mm(148.0), Vec::new()));
let bytes = document.save(&PdfSaveOptions::default(), &mut Vec::new());
let path = std::env::temp_dir().join("ocs_gdi_print_probe.pdf");
std::fs::write(&path, &bytes).unwrap();
let page = crate::scene::model::pdf_raster::rasterize_page_at_dpi(
&path.to_string_lossy(),
"1",
super::GDI_PRINT_DPI,
)
.expect("plot PDF should rasterise");
let _ = std::fs::remove_file(&path);
assert_eq!(page.dpi, super::GDI_PRINT_DPI);
// 210mm at 300 DPI ≈ 2480 px, aspect 210:148.
assert!((page.width as f64 - 210.0 / 25.4 * 300.0).abs() < 2.0);
assert!((page.height as f64 - 148.0 / 25.4 * 300.0).abs() < 2.0);
assert_eq!(
page.pixels.len(),
page.width as usize * page.height as usize * 4
);
}
/// Opening the printer DC and reading its printable area must work with
/// the device-name resolution the GDI fallback uses. No job is spooled.
#[cfg(target_os = "windows")]
#[test]
fn gdi_fallback_opens_a_printer_dc() {
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Graphics::Gdi::{
CreateDCW, DeleteDC, GetDeviceCaps, HORZRES, VERTRES,
};
let wide = |s: &str| -> Vec<u16> {
std::ffi::OsStr::new(s)
.encode_wide()
.chain(Some(0))
.collect()
};
let winspool = wide("WINSPOOL");
let device = wide("Microsoft Print to PDF");
// SAFETY: NUL-terminated strings; the DC is released before returning.
let hdc = unsafe {
CreateDCW(
winspool.as_ptr(),
device.as_ptr(),
std::ptr::null(),
std::ptr::null(),
)
};
assert!(!hdc.is_null(), "printer DC should open");
// SAFETY: capability indexes on a valid DC.
let (w, h) = unsafe {
(
GetDeviceCaps(hdc, HORZRES as i32),
GetDeviceCaps(hdc, VERTRES as i32),
)
};
// SAFETY: releasing the DC opened above.
unsafe { DeleteDC(hdc) };
assert!(
w > 0 && h > 0,
"printable area should be reported, got {w}x{h}"
);
}
/// End to end through the real spooler: plot a page with black ink, run
/// the GDI fallback against "Microsoft Print to PDF" redirected to a file
/// (no dialog, no paper), then rasterise the result and require that ink
/// actually landed on the page. Catches off-page draws (the blank-paper
/// regression) without a physical printer.
#[cfg(target_os = "windows")]
#[test]
fn gdi_fallback_lands_ink_on_the_printed_page() {
use printpdf::{Color, Mm, PdfDocument, PdfPage as OutputPage, PdfSaveOptions, Rgb};
let mut document = PdfDocument::new("gdi ink test");
let mut ops = Vec::new();
// A big black block in the middle of an A4 page.
ops.push(printpdf::Op::SetFillColor {
col: Color::Rgb(Rgb {
r: 0.0,
g: 0.0,
b: 0.0,
icc_profile: None,
}),
});
// printpdf 0.9's DrawRectangle never paints (its serializer always
// ends the path with `n`), so the ink must be a filled polygon — the
// same op the plot pipeline draws hatches with.
ops.push(printpdf::Op::DrawPolygon {
polygon: printpdf::Polygon {
rings: vec![printpdf::PolygonRing {
points: vec![
printpdf::LinePoint {
p: printpdf::Point {
x: Mm(60.0).into(),
y: Mm(60.0).into(),
},
bezier: false,
},
printpdf::LinePoint {
p: printpdf::Point {
x: Mm(150.0).into(),
y: Mm(60.0).into(),
},
bezier: false,
},
printpdf::LinePoint {
p: printpdf::Point {
x: Mm(150.0).into(),
y: Mm(100.0).into(),
},
bezier: false,
},
printpdf::LinePoint {
p: printpdf::Point {
x: Mm(60.0).into(),
y: Mm(100.0).into(),
},
bezier: false,
},
],
}],
mode: printpdf::PaintMode::Fill,
winding_order: printpdf::WindingOrder::NonZero,
},
});
document
.pages
.push(OutputPage::new(Mm(210.0), Mm(148.0), ops));
let bytes = document.save(&PdfSaveOptions::default(), &mut Vec::new());
let plot = std::env::temp_dir().join("ocs_gdi_ink_source.pdf");
std::fs::write(&plot, &bytes).unwrap();
let printed = std::env::temp_dir().join("ocs_gdi_ink_output.pdf");
let _ = std::fs::remove_file(&printed);
let result = super::gdi_raster_print(&plot, "Microsoft Print to PDF", 1, Some(&printed));
assert!(result.is_ok(), "GDI print failed: {result:?}");
// The spooled PDF must contain the black block: rasterise it and
// count clearly-dark pixels.
let page = crate::scene::model::pdf_raster::rasterize_page(&printed.to_string_lossy(), "1")
.expect("spooled output should rasterise");
let dark = page
.pixels
.chunks_exact(4)
.filter(|px| px[0] < 100 && px[1] < 100 && px[2] < 100)
.count();
std::fs::remove_file(&plot).ok();
std::fs::remove_file(&printed).ok();
assert!(
dark > page.width as usize * page.height as usize / 50,
"printed page should carry ink; only {dark} dark pixels"
);
}
#[cfg(target_os = "windows")]
/// The plot sheet's orientation must drive the printer's DEVMODE: a
/// landscape sheet spooled through "Microsoft Print to PDF" (whose
/// default is portrait) has to come out as a landscape page.
#[cfg(target_os = "windows")]
#[test]
fn gdi_fallback_prints_landscape_sheets_landscape() {
use printpdf::{Color, Mm, PdfDocument, PdfPage as OutputPage, PdfSaveOptions, Rgb};
let mut document = PdfDocument::new("gdi orientation test");
let mut ops = Vec::new();
ops.push(printpdf::Op::SetFillColor {
col: Color::Rgb(Rgb {
r: 0.0,
g: 0.0,
b: 0.0,
icc_profile: None,
}),
});
ops.push(printpdf::Op::DrawPolygon {
polygon: printpdf::Polygon {
rings: vec![printpdf::PolygonRing {
points: vec![
printpdf::LinePoint {
p: printpdf::Point {
x: Mm(20.0).into(),
y: Mm(20.0).into(),
},
bezier: false,
},
printpdf::LinePoint {
p: printpdf::Point {
x: Mm(270.0).into(),
y: Mm(20.0).into(),
},
bezier: false,
},
printpdf::LinePoint {
p: printpdf::Point {
x: Mm(270.0).into(),
y: Mm(45.0).into(),
},
bezier: false,
},
printpdf::LinePoint {
p: printpdf::Point {
x: Mm(20.0).into(),
y: Mm(45.0).into(),
},
bezier: false,
},
],
}],
mode: printpdf::PaintMode::Fill,
winding_order: printpdf::WindingOrder::NonZero,
},
});
// Landscape A4: 297 × 210 mm.
document
.pages
.push(OutputPage::new(Mm(297.0), Mm(210.0), ops));
let bytes = document.save(&PdfSaveOptions::default(), &mut Vec::new());
let plot = std::env::temp_dir().join("ocs_gdi_orientation_source.pdf");
std::fs::write(&plot, &bytes).unwrap();
let printed = std::env::temp_dir().join("ocs_gdi_orientation_output.pdf");
let _ = std::fs::remove_file(&printed);
let result = super::gdi_raster_print(&plot, "Microsoft Print to PDF", 1, Some(&printed));
assert!(result.is_ok(), "GDI print failed: {result:?}");
let page = crate::scene::model::pdf_raster::rasterize_page(&printed.to_string_lossy(), "1")
.expect("spooled output should rasterise");
std::fs::remove_file(&plot).ok();
std::fs::remove_file(&printed).ok();
// Whichever mechanism wins — DEVMODE orientation or the raster
// rotation fallback — the ink must land on the page and the plot's
// long side must run along the page's long side.
let mut min_x = page.width;
let mut max_x = 0u32;
let mut min_y = page.height;
let mut max_y = 0u32;
let mut dark = 0usize;
for y in 0..page.height {
for x in 0..page.width {
let px = &page.pixels
[((y * page.width + x) * 4) as usize..((y * page.width + x) * 4 + 3) as usize];
if px.iter().all(|&c| c < 100) {
dark += 1;
min_x = min_x.min(x);
max_x = max_x.max(x);
min_y = min_y.min(y);
max_y = max_y.max(y);
}
}
}
assert!(
dark > 1_000,
"printed page must carry ink; {dark} dark pixels"
);
let ink_w = (max_x - min_x + 1) as f64;
let ink_h = (max_y - min_y + 1) as f64;
assert_eq!(
ink_w > ink_h,
page.width > page.height,
"ink aspect must follow the page: ink {:.0}x{:.0} on page {}x{}",
ink_w,
ink_h,
page.width,
page.height
);
}
#[cfg(target_os = "windows")]
#[test]
fn windows_opens_selected_printer_or_printer_list() {
assert_eq!(
printer_properties_command(Some(" Office LaserJet ")),
(
"rundll32.exe",
vec![
"printui.dll,PrintUIEntry".to_string(),
"/e".to_string(),
"/n".to_string(),
"Office LaserJet".to_string(),
],
),
);
assert_eq!(
printer_properties_command(None),
("control.exe", vec!["printers".to_string()]),
);
}
/// The driver's DEVMODE has to come back for a real printer. It stopped
/// coming back once the size query asked with `DM_OUT_BUFFER` and no
/// output buffer, which drivers answer with -1: `with_printer_devmode`
/// then returned `None` for every printer, so the plot's sheet and
/// orientation never reached the job's DEVMODE and the printer report
/// could never name a default sheet.
#[cfg(target_os = "windows")]
#[test]
fn printer_devmode_comes_back_for_a_real_printer() {
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Graphics::Gdi::DEVMODEW;
let device: Vec<u16> = std::ffi::OsStr::new("Microsoft Print to PDF")
.encode_wide()
.chain(Some(0))
.collect();
let devmode = super::with_printer_devmode(&device, |_, _| true)
.expect("the driver must report a DEVMODE for an installed printer");
assert!(
devmode.len() >= std::mem::size_of::<DEVMODEW>(),
"DEVMODE buffer is {} bytes, shorter than the public struct",
devmode.len()
);
// SAFETY: the buffer holds at least a public DEVMODEW, just checked.
let named = unsafe { (*(devmode.as_ptr() as *const DEVMODEW)).dmSize } as usize;
assert!(
named > 0 && named <= devmode.len(),
"dmSize {named} outside the buffer"
);
// And the symptom the lost DEVMODE produced: the driver's default
// sheet is one of the sheets it lists, so the report can name it.
let caps = crate::io::plot_device::printer_capabilities("Microsoft Print to PDF")
.expect("an installed printer reports sheets");
assert!(
caps.default_paper.is_some(),
"the driver's default sheet should resolve to one of its {} listed sheets",
caps.media.len()
);
}
/// The plot's orientation has to survive into the DEVMODE the print job
/// is spooled with. While the DEVMODE query was broken this silently
/// returned `None` for every printer and `CreateDC` fell back to the
/// driver's own default, so a landscape plot came out however the driver
/// happened to be set. The GDI ink tests cannot catch that: they accept
/// the raster-rotation fallback as an equally good outcome.
#[cfg(target_os = "windows")]
#[test]
fn the_plot_orientation_reaches_the_job_devmode() {
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Graphics::Gdi::{
DEVMODEW, DMORIENT_LANDSCAPE, DMORIENT_PORTRAIT, DM_ORIENTATION,
};
let device: Vec<u16> = std::ffi::OsStr::new("Microsoft Print to PDF")
.encode_wide()
.chain(Some(0))
.collect();
for (landscape, want) in [(false, DMORIENT_PORTRAIT), (true, DMORIENT_LANDSCAPE)] {
let buf = super::oriented_printer_devmode(&device, landscape, None)
.unwrap_or_else(|| panic!("landscape={landscape}: the driver reported no DEVMODE"));
// SAFETY: `with_printer_devmode` sizes the buffer from the driver.
let dm = unsafe { &*(buf.as_ptr() as *const DEVMODEW) };
assert!(
dm.dmFields & DM_ORIENTATION != 0,
"landscape={landscape}: DM_ORIENTATION not marked as set"
);
// SAFETY: the printer branch of the DEVMODE union.
let got = unsafe { dm.Anonymous1.Anonymous1.dmOrientation };
assert_eq!(
got, want as i16,
"landscape={landscape}: wrong dmOrientation"
);
}
}
/// `printui` parses the raw command line itself, so a name that reaches it
/// split over several arguments matches no printer and it exits 0 without
/// drawing anything — a failure with nothing to show the user. The name
/// has to stay a single argument, directly after `/n`. The verb matters
/// too: `/e` is printing preferences, `/p` the device's admin sheet.
#[cfg(target_os = "windows")]
#[test]
fn windows_preferences_keep_the_printer_name_in_one_argument() {
for name in [
"Office Printer",
"Wide Format Plotter 1200",
"Εκτυπωτής γραφείου",
] {
let (program, args) = printer_properties_command(Some(name));
assert_eq!(program, "rundll32.exe");
assert!(
args.contains(&"/e".to_string()),
"{args:?} must ask for preferences"
);
assert!(
!args.contains(&"/p".to_string()),
"{args:?} must not ask for properties"
);
let flag = args
.iter()
.position(|arg| arg == "/n")
.expect("/n introduces the name");
assert_eq!(
args.get(flag + 1).map(String::as_str),
Some(name),
"{args:?}"
);
assert_eq!(args.iter().filter(|arg| arg.as_str() == name).count(), 1);
}
}
#[cfg(target_os = "macos")]
#[test]
fn macos_opens_print_settings() {
let expected = (
"open",
vec!["x-apple.systempreferences:com.apple.Print-Scan-Settings.extension".to_string()],
);
assert_eq!(
printer_properties_command(Some(" Office LaserJet ")),
expected.clone(),
);
assert_eq!(printer_properties_command(None), expected);
}
#[cfg(all(not(target_os = "windows"), not(target_os = "macos")))]
#[test]
fn unix_opens_selected_cups_printer_or_printer_list() {
assert_eq!(
printer_properties_command(Some(" Office LaserJet ")),
(
"xdg-open",
vec!["http://localhost:631/printers/Office LaserJet".to_string()],
),
);
assert_eq!(
printer_properties_command(None),
(
"xdg-open",
vec!["http://localhost:631/printers".to_string()],
),
);
}
#[test]
fn a_blank_selection_is_treated_as_no_selection() {
assert_eq!(
printer_properties_command(Some(" ")),
printer_properties_command(None),
);
}
}
// ── Driver options (CUPS PPD) ─────────────────────────────────────────────
/// One option a CUPS printer's driver exposes, as `lpoptions -l` lists it:
/// the PPD keyword, its description, the choices, and the current default.
#[derive(Clone, Debug, PartialEq)]
pub struct PrinterOption {
/// PPD keyword, e.g. `MediaType`, passed back verbatim as `-o key=value`.
pub key: String,
/// The driver's description, e.g. `Media Type`.
pub label: String,
/// Keyword of the currently selected choice.
pub default: String,
pub choices: Vec<PrinterChoice>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct PrinterChoice {
/// PPD choice keyword, e.g. `PhotographicHighGloss`.
pub keyword: String,
/// Readable form of the keyword, e.g. `Photographic High Gloss`.
pub label: String,
}
impl PrinterOption {
pub fn label_for(&self, keyword: &str) -> String {
self.choices
.iter()
.find(|choice| choice.keyword == keyword)
.map(|choice| choice.label.clone())
.unwrap_or_else(|| humanize_keyword(keyword))
}
}
/// Ask CUPS for the options of `printer` (blocking, one `lpoptions` spawn).
/// The sheet (`PageSize`) is left out: the plot dialog chooses it.
#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))]
pub fn printer_options(printer: &str) -> Result<Vec<PrinterOption>, String> {
let output = std::process::Command::new("lpoptions")
.args(["-p", printer, "-l"])
.output()
.map_err(|error| format!("lpoptions: {error}"))?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
}
Ok(parse_lpoptions(&String::from_utf8_lossy(&output.stdout)))
}
/// Windows drivers keep their options behind `DocumentProperties`; there is
/// no list to show in-line.
#[cfg(any(target_arch = "wasm32", target_os = "windows"))]
pub fn printer_options(_printer: &str) -> Result<Vec<PrinterOption>, String> {
Ok(Vec::new())
}
/// Parse `lpoptions -l` output: one option per line as
/// `Key/Description: choice *default choice …`, the asterisk marking the
/// current default. Numeric-range options print the same way and are kept.
pub fn parse_lpoptions(text: &str) -> Vec<PrinterOption> {
let mut options = Vec::new();
for line in text.lines() {
let Some((head, tail)) = line.split_once(':') else {
continue;
};
let (key, label) = head
.split_once('/')
.map(|(key, label)| (key.trim(), label.trim()))
.unwrap_or((head.trim(), head.trim()));
if key.is_empty() || key.eq_ignore_ascii_case("PageSize") {
continue;
}
let mut default = String::new();
let choices: Vec<PrinterChoice> = tail
.split_whitespace()
.map(|token| {
let (keyword, is_default) = match token.strip_prefix('*') {
Some(keyword) => (keyword, true),
None => (token, false),
};
if is_default {
default = keyword.to_string();
}
PrinterChoice {
keyword: keyword.to_string(),
label: humanize_keyword(keyword),
}
})
.collect();
if choices.is_empty() {
continue;
}
if default.is_empty() {
default = choices[0].keyword.clone();
}
options.push(PrinterOption {
key: key.to_string(),
label: if label.is_empty() {
key.to_string()
} else {
label.to_string()
},
default,
choices,
});
}
options
}
/// `PhotographicHighGloss` → `Photographic High Gloss`, `DuplexNoTumble` →
/// `Duplex No Tumble`; keywords that are already words or numbers pass
/// through. Consecutive capitals (`RGB`, `A4`) stay together.
pub fn humanize_keyword(keyword: &str) -> String {
let mut out = String::with_capacity(keyword.len() + 4);
let chars: Vec<char> = keyword.chars().collect();
for (index, &ch) in chars.iter().enumerate() {
if index > 0 && ch.is_ascii_uppercase() {
let previous = chars[index - 1];
let next_lower = chars.get(index + 1).is_some_and(|c| c.is_ascii_lowercase());
if previous.is_ascii_lowercase() || (previous.is_ascii_uppercase() && next_lower) {
out.push(' ');
}
}
out.push(ch);
}
out
}
/// The desktop environment's printer settings panel — where the driver
/// options cannot be listed (no CUPS) the user still gets somewhere useful.
/// Tries the panel of the running desktop first, then the
/// distribution-neutral tools, and only then the CUPS web interface.
#[cfg(target_os = "linux")]
pub fn open_desktop_printer_settings(printer: Option<&str>) -> Result<(), String> {
let desktop = std::env::var("XDG_CURRENT_DESKTOP")
.unwrap_or_default()
.to_ascii_lowercase();
let mut candidates: Vec<(&str, Vec<String>)> = Vec::new();
if desktop.contains("gnome") || desktop.contains("ubuntu") || desktop.contains("cinnamon") {
candidates.push(("gnome-control-center", vec!["printers".into()]));
}
if desktop.contains("kde") || desktop.contains("plasma") {
candidates.push(("kcmshell6", vec!["kcm_printer_manager".into()]));
candidates.push(("kcmshell5", vec!["kcm_printer_manager".into()]));
}
candidates.push(("system-config-printer", Vec::new()));
candidates.push(("gnome-control-center", vec!["printers".into()]));
for (program, args) in candidates {
if std::process::Command::new(program)
.args(&args)
.spawn()
.is_ok()
{
return Ok(());
}
}
let target = printer
.map(|name| format!("http://localhost:631/printers/{name}"))
.unwrap_or_else(|| "http://localhost:631/printers".to_string());
std::process::Command::new("xdg-open")
.arg(target)
.spawn()
.map(|_| ())
.map_err(|error| format!("Could not open printer settings: {error}"))
}
#[cfg(test)]
mod driver_option_tests {
use super::*;
const LPOPTIONS: &str = "PageSize/Media Size: 101.6x180.6mm *A4 A4.Borderless A5\n\
MediaType/Media Type: *Stationery PhotographicHighGloss Photographic Envelope\n\
ColorModel/Print Color Mode: *RGB Gray AutoGray ProcessGray\n\
Duplex/2-Sided Printing: *None DuplexNoTumble DuplexTumble\n\
cupsPrintQuality/Print Quality: *Normal High\n\
print-scaling/Print Scaling: auto auto-fit fill *fit none\n\
Broken line without colon\n";
#[test]
fn lpoptions_output_becomes_options_with_defaults() {
let options = parse_lpoptions(LPOPTIONS);
let keys: Vec<&str> = options.iter().map(|o| o.key.as_str()).collect();
// PageSize is the plot dialog's business; everything else is listed.
assert_eq!(
keys,
[
"MediaType",
"ColorModel",
"Duplex",
"cupsPrintQuality",
"print-scaling"
]
);
let media = &options[0];
assert_eq!(media.label, "Media Type");
assert_eq!(media.default, "Stationery");
assert_eq!(media.choices[1].keyword, "PhotographicHighGloss");
assert_eq!(media.choices[1].label, "Photographic High Gloss");
assert_eq!(media.label_for("Envelope"), "Envelope");
let scaling = &options[4];
assert_eq!(scaling.default, "fit");
assert_eq!(scaling.choices.len(), 5);
}
#[test]
fn keywords_read_as_words() {
assert_eq!(humanize_keyword("DuplexNoTumble"), "Duplex No Tumble");
assert_eq!(humanize_keyword("RGB"), "RGB");
assert_eq!(humanize_keyword("AutoGray"), "Auto Gray");
assert_eq!(humanize_keyword("ProcessGray"), "Process Gray");
assert_eq!(humanize_keyword("auto-fit"), "auto-fit");
assert_eq!(
humanize_keyword("PhotographicSemiGloss"),
"Photographic Semi Gloss"
);
assert_eq!(humanize_keyword("A4"), "A4");
}
}