Print on Windows: printer list, driver Print Settings, GDI printing

- photocraft-winprint: an isolated unsafe crate (like photocraft-tablet)
  for Windows printing: printer list, the driver's Print Settings dialog,
  page geometry, and GDI printing with ICM off.
- file.print prints on Windows (explicit "send": true only); new
  file.print.printers and file.print.page commands; printerSettings param.
- Print dialog on Windows: Printer menu, Print Settings…, paper from the
  driver.
- panic_hunt fuzzes print commands as dry runs.

Closes #2697.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
MoiztermindandClaude Opus 5.5 authored and moiztermind committed 2026-10-11 23:18:58 +01:00
1 parent 8f7cf009bb
commit 628a54b564
12 files changed
+933 -15

No files matched your search

Generated
+9
View File
@@ -4368,6 +4368,7 @@ dependencies = [
"photocraft-raw",
"photocraft-text",
"photocraft-vector",
"photocraft-winprint",
"rayon",
"roxmltree",
"serde",
@@ -4592,6 +4593,7 @@ dependencies = [
"photocraft-testkit",
"photocraft-text",
"photocraft-vector",
"photocraft-winprint",
"rayon",
"serde",
"serde_json",
@@ -4632,6 +4634,13 @@ dependencies = [
"web-sys",
]
[[package]]
name = "photocraft-winprint"
version = "0.7.0"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "pico-args"
version = "0.5.0"
+1
View File
@@ -36,6 +36,7 @@ photocraft-engine = { path = "crates/engine" }
photocraft-ui-egui = { path = "crates/ui-egui" }
photocraft-testkit = { path = "crates/testkit" }
photocraft-tablet = { path = "crates/tablet" }
photocraft-winprint = { path = "crates/winprint" }
# external
thiserror = "2"
+1
View File
@@ -19,6 +19,7 @@ photocraft-raster = { workspace = true }
photocraft-doc = { workspace = true }
photocraft-ops = { workspace = true }
photocraft-compose = { workspace = true }
photocraft-winprint = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
+109 -1
View File
@@ -353,6 +353,20 @@ fn do_print(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
};
let copies = crate::commands::int(p, "copies").unwrap_or(1).clamp(1, 999);
let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false);
// Windows has no `lp`: print through the driver (GDI, ICM off) instead of spooling a PDF.
// Only with an explicit `"send": true` (the dialog's Print button): a bare call, such as a
// fuzzer's or a script's `{}`, never puts paper through a printer. A dry run still renders
// the PDF and reports the `lp` line, as on every platform.
let explicit_send = p.get("send").and_then(Value::as_bool) == Some(true);
if cfg!(windows) && explicit_send && !dry && output.is_none() {
let printed = print_windows(&page, p, &doc.name, copies)?;
remember_print(s, p);
crate::automate_cmds::fire_event(s, "print");
return Ok(json!({
"pdf": Value::Null, "paper": [pw, ph], "imageRect": [x, y, w, h], "emulsionDown": emulsion_down, "scale": scale * 100.0,
"copies": copies, "command": Value::Null, "sent": true, "spooler": printed, "color": color,
}));
}
let pdf = print_pdf(&page);
write_file(&pdf_path, &pdf)?;
let mut argv: Vec<String> = vec!["lp".into()];
@@ -386,6 +400,80 @@ fn remember_print(s: &mut Session, p: &Value) {
s.file_menu.last_print = Some(remembered);
}
/// The `printer` parameter, or the system's default printer.
fn printer_name(p: &Value) -> Result<String> {
if let Some(name) = p.get("printer").and_then(Value::as_str).filter(|v| !v.trim().is_empty()) {
return Ok(name.to_string());
}
photocraft_winprint::default_printer().map_err(other)?.ok_or_else(|| other("no printer chosen and no default printer is set"))
}
/// The driver settings chosen with Print Settings… (`printerSettings`, hex), when any.
fn printer_settings(p: &Value) -> Result<Option<photocraft_winprint::DevMode>> {
match p.get("printerSettings").and_then(Value::as_str).filter(|v| !v.is_empty()) {
Some(hex) => photocraft_winprint::DevMode::from_hex(hex).map(Some).map_err(|e| bad("file.print", e.to_string())),
None => Ok(None),
}
}
/// The page's image as 8-bit RGB rows (gray expanded), mirrored for Emulsion Down.
pub fn page_rgb(page: &PrintPage) -> Result<Vec<u8>> {
let img = &page.image;
let (w, h) = (img.width as usize, img.height as usize);
let mut rgb = match img.channels {
3 => img.data.clone(),
1 => img.data.iter().flat_map(|g| [*g, *g, *g]).collect(),
_ => return Err(other("this printer profile is CMYK; Windows printers take RGB, so choose the printer's own (RGB) profile")),
};
if rgb.len() != w * h * 3 {
return Err(other("nothing to print"));
}
if page.emulsion_down {
for row in rgb.chunks_exact_mut(w * 3) {
row.as_chunks_mut::<3>().0.reverse();
}
}
Ok(rgb)
}
/// The image box with its origin at the sheet's top-left (GDI's), mirrored for Emulsion Down.
pub fn page_rect_top_left(page: &PrintPage) -> (f64, f64, f64, f64) {
let (pw, ph) = page.paper;
let (x, y, w, h) = page.rect;
let x = if page.emulsion_down { pw - x - w } else { x };
(x, ph - y - h, w, h)
}
/// Prints `page` on a Windows printer. Printer marks, description and labels are PDF-only.
fn print_windows(page: &PrintPage, p: &Value, title: &str, copies: i64) -> Result<Value> {
let printer = printer_name(p)?;
let settings = printer_settings(p)?;
let rgb = page_rgb(page)?;
let image = photocraft_winprint::RgbImage { width: page.image.width, height: page.image.height, data: &rgb };
let job = photocraft_winprint::Job { printer: &printer, settings: settings.as_ref(), title, output_file: None, copies: copies.clamp(1, 999) as u32 };
let done = photocraft_winprint::print(&job, &image, page_rect_top_left(page)).map_err(other)?;
let marks = page.marks.corner_crop || page.marks.center_crop || page.marks.registration || page.description.is_some() || page.label.is_some();
Ok(
json!({"printer": printer, "dpi": [done.dpi.0, done.dpi.1], "deviceRect": [done.device_rect.0, done.device_rect.1, done.device_rect.2, done.device_rect.3], "marksSkipped": marks}),
)
}
fn printers(_: &mut Session, _: &Value) -> Result<Value> {
match photocraft_winprint::printers() {
Ok(list) => Ok(json!({"supported": true, "printers": list.iter().map(|p| json!({"name": p.name, "default": p.default})).collect::<Vec<_>>()})),
Err(photocraft_winprint::PrintError::Unsupported) => Ok(json!({"supported": false, "printers": []})),
Err(e) => Err(other(e)),
}
}
fn printer_page(_: &mut Session, p: &Value) -> Result<Value> {
let printer = printer_name(p)?;
let pg = photocraft_winprint::page(&printer, printer_settings(p)?.as_ref()).map_err(other)?;
Ok(
json!({"printer": printer, "paper": [pg.paper.0, pg.paper.1], "printable": [pg.printable.0, pg.printable.1, pg.printable.2, pg.printable.3], "dpi": [pg.dpi.0, pg.dpi.1]}),
)
}
#[cfg(not(target_arch = "wasm32"))]
fn spool_pdf(argv: &[String]) -> Result<String> {
let out = std::process::Command::new(&argv[0]).args(&argv[1..]).output().map_err(|e| other(format!("could not run `lp` (CUPS): {e}")))?;
@@ -567,9 +655,29 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec { id: $id, label: $label, menu: $menu, shortcut: $sc, params: $params, enabled: $enabled, journal: true, run: $run }
};
}
const PRINT_PARAMS: &str = r##"{"printer":name? (default printer),"copies":1..999=1,"paper":"letter|legal|tabloid|a3|a4|a5|4x6|5x7"|[w,h] pt="letter","orientation":"portrait|landscape"="portrait","center":bool=true,"top":in?,"left":in?,"scale":%=100,"scaleToFit":bool=false,"colorHandling":"printerManages|photocraftManages|noColorManagement"="printerManages","printerProfile":profile? (photocraftManages),"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true,"cornerCropMarks":bool,"centerCropMarks":bool,"registrationMarks":bool,"description":bool,"labels":bool,"emulsionDown":bool=false (mirror the page left to right, for sublimation and other transfer printing),"output":pdf path? (print to PDF; then "send" defaults to false),"send":bool?,"dryRun":bool=false (render the PDF, report the lp command, don't spool)} → {pdf, imageRect, command, sent}"##;
const PRINT_PARAMS: &str = r##"{"printer":name? (default printer),"printerSettings":hex? (the driver settings from Print Settings…, Windows),"copies":1..999=1,"paper":"letter|legal|tabloid|a3|a4|a5|4x6|5x7"|[w,h] pt="letter","orientation":"portrait|landscape"="portrait","center":bool=true,"top":in?,"left":in?,"scale":%=100,"scaleToFit":bool=false,"colorHandling":"printerManages|photocraftManages|noColorManagement"="printerManages","printerProfile":profile? (photocraftManages),"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true,"cornerCropMarks":bool,"centerCropMarks":bool,"registrationMarks":bool,"description":bool,"labels":bool,"emulsionDown":bool=false (mirror the page left to right, for sublimation and other transfer printing),"output":pdf path? (print to PDF; then "send" defaults to false),"send":bool? (Windows prints to the printer only with an explicit true),"dryRun":bool=false (render the PDF, report the lp command, don't spool)} → {pdf, imageRect, command, sent}"##;
vec![
spec!("file.print", "Print…", &["File"], Some("Cmd+P"), PRINT_PARAMS, native_doc, print),
CommandSpec {
id: "file.print.printers",
label: "Printers",
menu: &[],
shortcut: None,
params: r##"{} → {supported, printers: [{name, default}]} (supported: Windows; elsewhere Print spools with lp)"##,
enabled: |_| Ok(()),
journal: false,
run: printers,
},
CommandSpec {
id: "file.print.page",
label: "Printer Page",
menu: &[],
shortcut: None,
params: r##"{"printer":name? (default printer),"printerSettings":hex?} → {printer, paper: [w,h] pt, printable: [x,y,w,h] pt from the top-left, dpi: [x,y]} (Windows)"##,
enabled: |_| Ok(()),
journal: false,
run: printer_page,
},
spec!(
"file.printOneCopy",
"Print One Copy",
+35
View File
@@ -133,6 +133,41 @@ fn emulsion_down_mirrors_the_whole_page() {
}
}
fn page_of(channels: usize, data: Vec<u8>, emulsion_down: bool) -> PrintPage {
PrintPage {
paper: (600.0, 800.0),
image: PrintImage { width: 2, height: 1, channels, data, icc: None },
rect: (100.0, 200.0, 144.0, 72.0),
marks: Marks::default(),
description: None,
label: None,
emulsion_down,
}
}
#[test]
fn windows_print_image_is_rgb_mirrored_for_emulsion_down() {
assert_eq!(page_rgb(&page_of(3, vec![1, 2, 3, 4, 5, 6], false)).unwrap(), [1, 2, 3, 4, 5, 6]);
assert_eq!(page_rgb(&page_of(3, vec![1, 2, 3, 4, 5, 6], true)).unwrap(), [4, 5, 6, 1, 2, 3]);
assert_eq!(page_rgb(&page_of(1, vec![7, 9], true)).unwrap(), [9, 9, 9, 7, 7, 7]);
assert!(page_rgb(&page_of(4, vec![0; 8], false)).is_err(), "CMYK can't go to a GDI printer");
assert!(page_rgb(&page_of(3, vec![0; 5], false)).is_err(), "short data");
// GDI's origin is the sheet's top-left; Emulsion Down moves the box to the mirrored side.
assert_eq!(page_rect_top_left(&page_of(3, vec![0; 6], false)), (100.0, 528.0, 144.0, 72.0));
assert_eq!(page_rect_top_left(&page_of(3, vec![0; 6], true)), (356.0, 528.0, 144.0, 72.0));
}
#[test]
fn printer_commands_answer_without_a_document_and_reject_bad_settings() {
let mut s = Session::new();
let r = s.execute("file.print.printers", json!({})).unwrap();
assert_eq!(r["supported"], cfg!(windows));
assert!(r["printers"].is_array());
assert!(s.execute("file.print.page", json!({"printer": "Anything", "printerSettings": "zz"})).is_err());
let mut s = session("rgb", 8);
assert!(s.execute("file.print", json!({"printer": "Anything", "printerSettings": "0102", "send": true})).is_err());
}
#[test]
fn printer_profile_can_be_an_installed_icc_file() {
let dir = tmp("icc");
+9
View File
@@ -61,6 +61,15 @@ fn no_command_panics_or_hangs_on_adversarial_params() {
let mut bad: Vec<String> = Vec::new();
for spec in command_specs() {
for p in &params {
// Print and Print One Copy reach real printers (lp, or the Windows driver): fuzz them
// as dry runs so the hunt never prints a page.
let mut p = p.clone();
if spec.id.starts_with("file.print")
&& let Some(o) = p.as_object_mut()
{
o.insert("dryRun".into(), json!(true));
}
let p = &p;
if let Err(why) = run_guarded(spec.id, p) {
bad.push(format!("{} [{why}] <- {p}", spec.id));
break;
+1
View File
@@ -12,6 +12,7 @@ craft-ui = { workspace = true }
photocraft-geom = { workspace = true }
photocraft-color = { workspace = true }
photocraft-cms = { workspace = true }
photocraft-winprint = { workspace = true }
photocraft-raster = { workspace = true }
photocraft-doc = { workspace = true }
photocraft-compose = { workspace = true }
+99 -14
View File
@@ -593,9 +593,36 @@ pub fn open_print(app: &mut PhotocraftApp) -> u64 {
] {
f.entry(k.to_string()).or_insert(v);
}
// Windows: the system's printers, and the paper from the chosen printer's driver settings.
if let Ok(list) = photocraft_winprint::printers() {
f.insert("__printers".into(), json!(list.iter().map(|p| p.name.clone()).collect::<Vec<_>>()));
let known = |n: &str| list.iter().any(|p| p.name == n);
if !known(&s(&f, "printer", "")) {
let fallback = list.iter().find(|p| p.default).or(list.first()).map(|p| p.name.clone()).unwrap_or_default();
f.insert("printer".into(), json!(fallback));
f.remove("printerSettings");
}
driver_page(&mut f);
}
app.ui.open_dialog(DialogKind::Command, f)
}
/// Takes the paper from the printer's driver settings (Windows), as Photoshop does.
fn driver_page(f: &mut Map<String, Value>) {
let printer = s(f, "printer", "");
let settings = f.get("printerSettings").and_then(Value::as_str).and_then(|h| photocraft_winprint::DevMode::from_hex(h).ok());
match photocraft_winprint::page(&printer, settings.as_ref()) {
Ok(pg) => {
f.insert("paper".into(), json!([pg.paper.0, pg.paper.1]));
f.insert("orientation".into(), json!("portrait"));
f.remove("__pageError");
}
Err(e) => {
f.insert("__pageError".into(), json!(e.to_string()));
}
}
}
/// Points per unit for the Position and Size fields.
fn unit_pt(units: &str) -> f64 {
match units {
@@ -702,19 +729,61 @@ fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Va
ui.label(egui::RichText::new(s).font(crate::theme::semibold(12.0)).color(t.text));
};
head(ui, tl!("Printer Setup"));
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!("Printer")).color(t.text_dim));
let mut pr = s(f, "printer", "");
if ui.add(egui::TextEdit::singleline(&mut pr).hint_text(tl!("Default printer")).desired_width(180.0)).changed() {
f.insert("printer".into(), json!(pr));
let printers: Option<Vec<String>> =
f.get("__printers").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect());
if let Some(printers) = &printers {
// Windows: Photoshop's Printer menu and Print Settings… (the driver's own dialog).
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!("Printer")).color(t.text_dim));
let before = s(f, "printer", "");
let opts: Vec<(&str, &str)> = printers.iter().map(|n| (n.as_str(), n.as_str())).collect();
dropdown_str(ui, "print-printer", f, "printer", &opts, 220.0);
if s(f, "printer", "") != before {
f.remove("printerSettings");
driver_page(f);
}
});
ui.horizontal(|ui| {
number(ui, f, "copies", tl!("Copies"), 1.0..=999.0, "", 1.0);
if ui.button(tl!("Print Settings…")).clicked() {
let printer = s(f, "printer", "");
let current = f.get("printerSettings").and_then(Value::as_str).and_then(|h| photocraft_winprint::DevMode::from_hex(h).ok());
match photocraft_winprint::settings_dialog(&printer, current.as_ref(), None) {
Ok(Some(dm)) => {
f.insert("printerSettings".into(), json!(dm.to_hex()));
driver_page(f);
}
Ok(None) => {}
Err(e) => {
f.insert("__pageError".into(), json!(e.to_string()));
}
}
}
});
let paper = p.get("paper").and_then(Value::as_array).map(|a| (a.first().and_then(Value::as_f64), a.get(1).and_then(Value::as_f64)));
let text = match paper {
Some((Some(w), Some(h))) => format!("{:.0} × {:.0} mm · {}", w / 72.0 * 25.4, h / 72.0 * 25.4, tl!("set in Print Settings…")),
_ => String::new(),
};
ui.label(egui::RichText::new(text).color(t.text_dim).size(11.0));
if let Some(e) = f.get("__pageError").and_then(Value::as_str) {
ui.label(egui::RichText::new(e).color(t.warning).size(11.0));
}
});
number(ui, f, "copies", tl!("Copies"), 1.0..=999.0, "", 1.0);
ui.horizontal(|ui| {
let papers: Vec<(&str, &str)> = photocraft_engine::print_cmds::PAPERS.iter().map(|p| (p.0, p.0)).collect();
dropdown_str(ui, "print-paper", f, "paper", &papers, 110.0);
dropdown_str(ui, "print-orient", f, "orientation", &[("portrait", tl!("Portrait")), ("landscape", tl!("Landscape"))], 110.0);
});
} else {
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!("Printer")).color(t.text_dim));
let mut pr = s(f, "printer", "");
if ui.add(egui::TextEdit::singleline(&mut pr).hint_text(tl!("Default printer")).desired_width(180.0)).changed() {
f.insert("printer".into(), json!(pr));
}
});
number(ui, f, "copies", tl!("Copies"), 1.0..=999.0, "", 1.0);
ui.horizontal(|ui| {
let papers: Vec<(&str, &str)> = photocraft_engine::print_cmds::PAPERS.iter().map(|p| (p.0, p.0)).collect();
dropdown_str(ui, "print-paper", f, "paper", &papers, 110.0);
dropdown_str(ui, "print-orient", f, "orientation", &[("portrait", tl!("Portrait")), ("landscape", tl!("Landscape"))], 110.0);
});
}
head(ui, tl!("Color Management"));
dropdown_str(
ui,
@@ -766,6 +835,11 @@ fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Va
);
check(ui, f, "bpc", tl!("Black Point Compensation"), true);
});
if printers.is_some() {
ui.label(
egui::RichText::new(tl!("Remember to turn off the printer's own colour management in Print Settings…")).color(t.warning).size(11.0),
);
}
}
head(ui, tl!("Position and Size"));
dropdown_str(ui, "print-units", f, "units", &[("cm", tl!("Centimeters")), ("mm", tl!("Millimeters")), ("in", tl!("Inches"))], 130.0);
@@ -829,6 +903,9 @@ fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Va
});
head(ui, tl!("Functions"));
check(ui, f, "emulsionDown", tl!("Emulsion Down"), false);
if b(f, "emulsionDown", false) && printers.is_some() {
ui.label(egui::RichText::new(tl!("Don't also tick Mirror Image in Print Settings…: the two flips cancel out.")).color(t.text_dim).size(11.0));
}
head(ui, tl!("Save as PDF"));
let mut out = s(f, "output", "");
if ui.add(egui::TextEdit::singleline(&mut out).hint_text(tl!("(print to the printer)")).desired_width(300.0)).changed() {
@@ -839,8 +916,16 @@ fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Va
}
fn print_confirm(app: &mut PhotocraftApp, f: &Map<String, Value>) -> Result<Value, String> {
let r = app.run("file.print", params(f))?;
app.ui.status = if r["sent"] == json!(true) {
let mut p = params(f);
// The Print button prints (an empty Save as PDF path means the printer).
if s(f, "output", "").is_empty() {
p["send"] = json!(true);
}
let r = app.run("file.print", p)?;
app.ui.status = if let Some(printer) = r["spooler"]["printer"].as_str() {
let skipped = if r["spooler"]["marksSkipped"] == json!(true) { " (printer marks and labels are only in Save as PDF)" } else { "" };
format!("Sent to {printer}{skipped}")
} else if r["sent"] == json!(true) {
let template = "Sent to the printer ({spooler})";
crate::i18n::fmt(tl!(template), &[("spooler", r["spooler"].as_str().unwrap_or(""))])
} else {
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "photocraft-winprint"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Windows printing: printer list, the driver's Print Settings dialog, page geometry and GDI printing with ICM off"
# An isolated `unsafe` helper crate like photocraft-tablet (craftrules never-crash, AGENTS.md):
# Windows printing needs Win32 calls. Only `src/windows.rs` allows `unsafe`; every block has a
# `SAFETY:` comment. Elsewhere every function returns `Unsupported`.
[lints.rust]
unsafe_code = "deny"
[lints.clippy]
dbg_macro = "warn"
todo = "warn"
# Already in Cargo.lock (winit/wgpu pull it in); pinned exactly: review the diff before bumping.
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "=0.61.2", features = [
"Win32_Foundation",
"Win32_Graphics_Gdi",
"Win32_Graphics_Printing",
"Win32_Storage_Xps",
"Win32_UI_ColorSystem",
] }
+263
View File
@@ -0,0 +1,263 @@
//! Windows printing for File › Print: the system's printers, the driver's own Print Settings
//! dialog (paper, paper type, quality, the driver's colour correction and mirror), the page
//! geometry it implies, and printing an 8-bit RGB image through GDI with ICM off, so colours
//! converted with a printer profile ("PhotoCraft Manages Colors") reach the driver unchanged.
//!
//! The driver settings travel as an opaque DEVMODE blob ([`DevMode`]), validated before use.
//! Everything here is a safe API; the Win32 calls live in `windows.rs`. Other platforms get
//! [`PrintError::Unsupported`].
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(windows)]
#[allow(unsafe_code)]
mod windows;
use std::fmt;
/// Why a printing call failed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PrintError {
/// Not on Windows.
Unsupported,
/// Bad input (an unknown printer, a malformed DEVMODE, an empty image).
Invalid(String),
/// A Win32 call failed.
System(String),
}
impl fmt::Display for PrintError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PrintError::Unsupported => f.write_str("printing to a printer needs Windows here"),
PrintError::Invalid(m) | PrintError::System(m) => f.write_str(m),
}
}
}
impl std::error::Error for PrintError {}
pub type Result<T> = std::result::Result<T, PrintError>;
/// One installed printer.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Printer {
pub name: String,
pub default: bool,
}
/// A printer driver's settings (a Win32 DEVMODEW with the driver's private part), as bytes.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DevMode(Vec<u8>);
/// Offsets inside DEVMODEW: `dmSize` and `dmDriverExtra` follow the 32-wchar device name and
/// two version words.
const DM_SIZE_AT: usize = 68;
const DM_EXTRA_AT: usize = 70;
/// The smallest DEVMODEW a driver writes (Windows 95's, up to `dmICMIntent`).
const DM_MIN: usize = 156;
/// Larger blobs are not driver settings.
const DM_MAX: usize = 1 << 20;
impl DevMode {
/// Validates driver settings bytes: the header's own sizes must add up to the length.
pub fn from_bytes(b: Vec<u8>) -> Result<Self> {
let word = |at: usize| b.get(at..at + 2).map(|w| usize::from(u16::from_le_bytes([w[0], w[1]])));
let (Some(size), Some(extra)) = (word(DM_SIZE_AT), word(DM_EXTRA_AT)) else {
return Err(PrintError::Invalid("printer settings are too short".into()));
};
if b.len() > DM_MAX || size < DM_MIN || size.checked_add(extra) != Some(b.len()) {
return Err(PrintError::Invalid("printer settings are damaged; choose Print Settings again".into()));
}
Ok(DevMode(b))
}
pub fn as_bytes(&self) -> &[u8] {
&self.0
}
/// Hex, for JSON parameters and remembered print settings.
pub fn to_hex(&self) -> String {
self.0.iter().map(|b| format!("{b:02x}")).collect()
}
pub fn from_hex(s: &str) -> Result<Self> {
if !s.len().is_multiple_of(2) || s.len() > DM_MAX * 2 {
return Err(PrintError::Invalid("printer settings are damaged; choose Print Settings again".into()));
}
let bytes: Option<Vec<u8>> = (0..s.len()).step_by(2).map(|i| s.get(i..i + 2).and_then(|h| u8::from_str_radix(h, 16).ok())).collect();
Self::from_bytes(bytes.ok_or_else(|| PrintError::Invalid("printer settings are damaged; choose Print Settings again".into()))?)
}
}
/// What a printer will print on with given settings. Points (1/72 in), origin top-left.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Page {
/// The whole sheet.
pub paper: (f64, f64),
/// The area the printer can reach: (x, y, width, height).
pub printable: (f64, f64, f64, f64),
/// Device resolution (dots per inch).
pub dpi: (u32, u32),
}
/// An 8-bit RGB image, rows top to bottom, `width * 3` bytes each.
pub struct RgbImage<'a> {
pub width: u32,
pub height: u32,
pub data: &'a [u8],
}
/// One print job.
pub struct Job<'a> {
pub printer: &'a str,
pub settings: Option<&'a DevMode>,
/// The name in the print queue.
pub title: &'a str,
/// Print to this file instead of the device (drivers that write files, and tests).
pub output_file: Option<&'a str>,
pub copies: u32,
}
/// Where a job went: the image box in device pixels and the device resolution.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Printed {
pub device_rect: (i32, i32, i32, i32),
pub dpi: (u32, u32),
}
/// Maps an image box in points (origin top-left of the sheet) to device pixels (origin at the
/// printable area's corner, as GDI draws).
pub fn device_rect(page: &Page, rect: (f64, f64, f64, f64)) -> (i32, i32, i32, i32) {
let (dx, dy) = (f64::from(page.dpi.0) / 72.0, f64::from(page.dpi.1) / 72.0);
let px = |v: f64| if v.is_finite() { v.round().clamp(-1e8, 1e8) as i32 } else { 0 };
let (x, y, w, h) = rect;
(px((x - page.printable.0) * dx), px((y - page.printable.1) * dy), px(w * dx), px(h * dy))
}
/// The printers installed for this user, the default first.
pub fn printers() -> Result<Vec<Printer>> {
#[cfg(windows)]
{
let mut v = windows::printers()?;
v.sort_by(|a, b| b.default.cmp(&a.default).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())));
Ok(v)
}
#[cfg(not(windows))]
Err(PrintError::Unsupported)
}
/// The default printer's name.
pub fn default_printer() -> Result<Option<String>> {
#[cfg(windows)]
{
windows::default_printer()
}
#[cfg(not(windows))]
Err(PrintError::Unsupported)
}
/// A printer's settings as its driver starts them.
pub fn default_settings(printer: &str) -> Result<DevMode> {
#[cfg(windows)]
{
windows::properties(printer, None, None, false)?.ok_or_else(|| PrintError::System("the printer driver gave no settings".into()))
}
#[cfg(not(windows))]
{
let _ = printer;
Err(PrintError::Unsupported)
}
}
/// Shows the driver's Print Settings dialog (modal; `parent` is a window handle). `None` when
/// the user cancels.
pub fn settings_dialog(printer: &str, current: Option<&DevMode>, parent: Option<isize>) -> Result<Option<DevMode>> {
#[cfg(windows)]
{
windows::properties(printer, current, parent, true)
}
#[cfg(not(windows))]
{
let _ = (printer, current, parent);
Err(PrintError::Unsupported)
}
}
/// The sheet and printable area for `printer` with `settings`.
pub fn page(printer: &str, settings: Option<&DevMode>) -> Result<Page> {
#[cfg(windows)]
{
windows::page(printer, settings)
}
#[cfg(not(windows))]
{
let _ = (printer, settings);
Err(PrintError::Unsupported)
}
}
/// Prints `image` into `rect` (points, origin top-left of the sheet), `job.copies` pages.
pub fn print(job: &Job<'_>, image: &RgbImage<'_>, rect: (f64, f64, f64, f64)) -> Result<Printed> {
let row = (image.width as usize).checked_mul(3).ok_or_else(|| PrintError::Invalid("image too large".into()))?;
if image.width == 0 || image.height == 0 || row.checked_mul(image.height as usize) != Some(image.data.len()) {
return Err(PrintError::Invalid("nothing to print".into()));
}
if !(rect.2 > 0.0 && rect.3 > 0.0) {
return Err(PrintError::Invalid("the print size is empty".into()));
}
#[cfg(windows)]
{
windows::print(job, image, rect)
}
#[cfg(not(windows))]
{
let _ = job;
Err(PrintError::Unsupported)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn devmode(size: u16, extra: u16) -> Vec<u8> {
let mut b = vec![0u8; usize::from(size) + usize::from(extra)];
b[DM_SIZE_AT..DM_SIZE_AT + 2].copy_from_slice(&size.to_le_bytes());
b[DM_EXTRA_AT..DM_EXTRA_AT + 2].copy_from_slice(&extra.to_le_bytes());
b
}
#[test]
fn devmode_checks_its_own_sizes_and_round_trips_hex() {
let d = DevMode::from_bytes(devmode(220, 1200)).unwrap();
assert_eq!(DevMode::from_hex(&d.to_hex()).unwrap(), d);
for bad in [vec![], vec![0; 40], devmode(100, 0), {
let mut b = devmode(220, 10);
b.pop();
b
}] {
assert!(DevMode::from_bytes(bad).is_err());
}
for bad in ["", "abc", "zz", &"00".repeat(300)] {
assert!(DevMode::from_hex(bad).is_err(), "{bad}");
}
}
#[test]
fn device_rect_is_relative_to_the_printable_corner() {
// A3 at 360 dpi with a 3 mm (8.5 pt) unprintable margin.
let page = Page { paper: (841.89, 1190.55), printable: (8.5, 8.5, 824.89, 1173.55), dpi: (360, 360) };
assert_eq!(device_rect(&page, (8.5, 8.5, 72.0, 36.0)), (0, 0, 360, 180));
assert_eq!(device_rect(&page, (72.0, 144.0, 144.0, 72.0)), (318, 678, 720, 360));
assert_eq!(device_rect(&page, (f64::NAN, f64::INFINITY, 1.0, 1.0)).0, 0);
}
#[test]
fn print_rejects_empty_or_short_images_before_any_system_call() {
let job = Job { printer: "x", settings: None, title: "t", output_file: None, copies: 1 };
let short = RgbImage { width: 2, height: 2, data: &[0; 11] };
assert!(matches!(print(&job, &short, (0.0, 0.0, 10.0, 10.0)), Err(PrintError::Invalid(_))));
let ok = RgbImage { width: 2, height: 2, data: &[0; 12] };
assert!(matches!(print(&job, &ok, (0.0, 0.0, 0.0, 10.0)), Err(PrintError::Invalid(_))));
}
}
+378
View File
@@ -0,0 +1,378 @@
//! The Win32 side: winspool (printers, driver settings) and GDI (page geometry, printing).
//! Every `unsafe` block says why it is sound. Buffers the system writes into are `u64`-backed so
//! the structures read from them are aligned.
use std::ptr::{null, null_mut};
use windows_sys::Win32::Foundation::HWND;
use windows_sys::Win32::Graphics::Gdi::{
BI_RGB, BITMAPINFO, BITMAPINFOHEADER, CreateDCW, DEVMODEW, DIB_RGB_COLORS, DM_IN_BUFFER, DM_IN_PROMPT, DM_OUT_BUFFER, DeleteDC, GetDeviceCaps, HALFTONE,
HDC, HORZRES, LOGPIXELSX, LOGPIXELSY, PHYSICALHEIGHT, PHYSICALOFFSETX, PHYSICALOFFSETY, PHYSICALWIDTH, SRCCOPY, SetBrushOrgEx, SetStretchBltMode,
StretchDIBits, VERTRES,
};
use windows_sys::Win32::Graphics::Printing::{
ClosePrinter, DocumentPropertiesW, EnumPrintersW, GetDefaultPrinterW, OpenPrinterW, PRINTER_ENUM_CONNECTIONS, PRINTER_ENUM_LOCAL, PRINTER_HANDLE,
PRINTER_INFO_4W,
};
use windows_sys::Win32::Storage::Xps::{AbortDoc, DOCINFOW, EndDoc, EndPage, StartDocW, StartPage};
use windows_sys::Win32::UI::ColorSystem::{ICM_OFF, SetICMMode};
use crate::{DevMode, Job, Page, PrintError, Printed, Printer, Result, RgbImage};
const IDOK: i32 = 1;
const IDCANCEL: i32 = 2;
/// Wide strings read from the system stop here at the latest.
const MAX_WCHARS: usize = 32 * 1024;
fn last_error(what: &str) -> PrintError {
PrintError::System(format!("{what} failed: {}", std::io::Error::last_os_error()))
}
fn wide(s: &str) -> Result<Vec<u16>> {
if s.contains('\0') {
return Err(PrintError::Invalid("a name contains a NUL character".into()));
}
Ok(s.encode_utf16().chain(Some(0)).collect())
}
/// A zeroed, 8-byte aligned buffer of at least `bytes` bytes.
fn aligned(bytes: usize) -> Vec<u64> {
vec![0u64; bytes.div_ceil(8).max(1)]
}
fn bytes_of(buf: &[u64], len: usize) -> Vec<u8> {
buf.iter().flat_map(|w| w.to_le_bytes()).take(len).collect()
}
/// Reads a NUL-terminated wide string.
///
/// # Safety
/// `p` is null or points to a NUL-terminated UTF-16 string that stays alive for the call.
unsafe fn read_wide(p: *const u16) -> String {
if p.is_null() {
return String::new();
}
let mut n = 0;
// SAFETY: the caller guarantees a NUL-terminated string; we stop at the NUL (or the cap).
while n < MAX_WCHARS && unsafe { *p.add(n) } != 0 {
n += 1;
}
// SAFETY: the `n` units before the NUL were just read and are valid.
String::from_utf16_lossy(unsafe { std::slice::from_raw_parts(p, n) })
}
pub fn printers() -> Result<Vec<Printer>> {
let flags = PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS;
let (mut needed, mut count) = (0u32, 0u32);
// SAFETY: a size query: no buffer (null, 0 bytes); the out-params are valid locals.
unsafe { EnumPrintersW(flags, null(), 4, null_mut(), 0, &mut needed, &mut count) };
if needed == 0 {
return Ok(Vec::new());
}
let mut buf = aligned(needed as usize);
// SAFETY: `buf` holds at least `needed` writable bytes, aligned for PRINTER_INFO_4W.
let ok = unsafe { EnumPrintersW(flags, null(), 4, buf.as_mut_ptr().cast(), needed, &mut needed, &mut count) };
if ok == 0 {
return Err(last_error("listing printers"));
}
let default = default_printer().ok().flatten();
let infos = buf.as_ptr().cast::<PRINTER_INFO_4W>();
let fits = (needed as usize) / std::mem::size_of::<PRINTER_INFO_4W>();
let mut out = Vec::new();
for i in 0..(count as usize).min(fits) {
// SAFETY: the system wrote `count` PRINTER_INFO_4W at the start of `buf` (bounded by
// the buffer size above); their name pointers point into `buf`, which is alive.
let name = unsafe { read_wide((*infos.add(i)).pPrinterName) };
if !name.is_empty() {
out.push(Printer { default: default.as_deref() == Some(name.as_str()), name });
}
}
Ok(out)
}
pub fn default_printer() -> Result<Option<String>> {
let mut len = 0u32;
// SAFETY: a size query with a null buffer; `len` is a valid local.
unsafe { GetDefaultPrinterW(null_mut(), &mut len) };
if len == 0 || len as usize > MAX_WCHARS {
return Ok(None);
}
let mut buf = vec![0u16; len as usize];
// SAFETY: `buf` holds `len` writable wide chars.
if unsafe { GetDefaultPrinterW(buf.as_mut_ptr(), &mut len) } == 0 {
return Ok(None);
}
// SAFETY: on success the buffer holds a NUL-terminated name.
Ok(Some(unsafe { read_wide(buf.as_ptr()) }))
}
/// An open printer handle, closed on drop.
struct OpenPrinter(PRINTER_HANDLE);
impl OpenPrinter {
fn open(name: &[u16]) -> Result<Self> {
let mut h = PRINTER_HANDLE { Value: null_mut() };
// SAFETY: `name` is NUL-terminated; `h` is a valid out-param; no defaults.
if unsafe { OpenPrinterW(name.as_ptr(), &mut h, null()) } == 0 || h.Value.is_null() {
return Err(last_error("opening the printer"));
}
Ok(OpenPrinter(h))
}
}
impl Drop for OpenPrinter {
fn drop(&mut self) {
// SAFETY: the handle came from OpenPrinterW and is closed once.
unsafe { ClosePrinter(self.0) };
}
}
/// Settings the driver can take as input: `current`, copied aligned, when it is this printer's.
fn input_settings(printer: &str, current: Option<&DevMode>) -> Option<Vec<u64>> {
let d = current?;
let b = d.as_bytes();
// dmDeviceName: the first 32 wide chars (31 + NUL), so long names are truncated.
let device: Vec<u16> = b.get(..64)?.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).take_while(|c| *c != 0).collect();
let want: Vec<u16> = printer.encode_utf16().take(31).collect();
if device != want {
return None;
}
let mut buf = aligned(b.len());
for (i, chunk) in b.chunks(8).enumerate() {
let mut w = [0u8; 8];
w[..chunk.len()].copy_from_slice(chunk);
if let Some(slot) = buf.get_mut(i) {
*slot = u64::from_le_bytes(w);
}
}
Some(buf)
}
pub fn properties(printer: &str, current: Option<&DevMode>, parent: Option<isize>, prompt: bool) -> Result<Option<DevMode>> {
let name = wide(printer)?;
let p = OpenPrinter::open(&name)?;
let hwnd: HWND = parent.map_or(null_mut(), |h| h as HWND);
// SAFETY: with fmode 0 DocumentPropertiesW only returns the size the settings need.
let needed = unsafe { DocumentPropertiesW(hwnd, p.0, name.as_ptr(), null_mut(), null(), 0) };
if needed <= 0 || needed as usize > (1 << 20) {
return Err(last_error("reading the printer's settings"));
}
let mut out = aligned(needed as usize);
let input = input_settings(printer, current);
let mut mode = DM_OUT_BUFFER;
if input.is_some() {
mode |= DM_IN_BUFFER;
}
if prompt {
mode |= DM_IN_PROMPT;
}
let inp: *const DEVMODEW = input.as_ref().map_or(null(), |b| b.as_ptr().cast());
// SAFETY: `out` holds `needed` aligned writable bytes as the size query asked for; `inp` is
// null or an aligned copy of settings written by this printer's driver.
let r = unsafe { DocumentPropertiesW(hwnd, p.0, name.as_ptr(), out.as_mut_ptr().cast(), inp, mode) };
match r {
IDOK => {
let all = bytes_of(&out, needed as usize);
let word = |at: usize| all.get(at..at + 2).map_or(0, |w| usize::from(u16::from_le_bytes([w[0], w[1]])));
let len = (word(68) + word(70)).min(all.len());
DevMode::from_bytes(all.into_iter().take(len).collect()).map(Some)
}
IDCANCEL => Ok(None),
_ => Err(last_error("the printer's settings dialog")),
}
}
/// A printer device context, deleted on drop.
struct Dc(HDC);
impl Dc {
fn create(printer: &str, settings: Option<&DevMode>) -> Result<Self> {
let name = wide(printer)?;
let dm = input_settings(printer, settings);
let dmp: *const DEVMODEW = dm.as_ref().map_or(null(), |b| b.as_ptr().cast());
// SAFETY: `name` is NUL-terminated; `dmp` is null or aligned settings from this
// printer's driver, alive for the call.
let hdc = unsafe { CreateDCW(null(), name.as_ptr(), null(), dmp) };
if hdc.is_null() {
return Err(last_error(&format!("opening printer “{printer}”")));
}
Ok(Dc(hdc))
}
fn cap(&self, index: u32) -> i32 {
// SAFETY: a valid printer DC and a documented capability index.
unsafe { GetDeviceCaps(self.0, index as i32) }
}
fn page(&self) -> Result<Page> {
let (dx, dy) = (self.cap(LOGPIXELSX), self.cap(LOGPIXELSY));
if dx <= 0 || dy <= 0 {
return Err(PrintError::System("the printer reported no resolution".into()));
}
let (fx, fy) = (72.0 / f64::from(dx), 72.0 / f64::from(dy));
let (pw, ph) = (self.cap(PHYSICALWIDTH), self.cap(PHYSICALHEIGHT));
let (ox, oy) = (self.cap(PHYSICALOFFSETX), self.cap(PHYSICALOFFSETY));
let (hw, vh) = (self.cap(HORZRES), self.cap(VERTRES));
let (pw, ph) = if pw > 0 && ph > 0 { (pw, ph) } else { (hw, vh) };
Ok(Page {
paper: (f64::from(pw) * fx, f64::from(ph) * fy),
printable: (f64::from(ox) * fx, f64::from(oy) * fy, f64::from(hw) * fx, f64::from(vh) * fy),
dpi: (dx as u32, dy as u32),
})
}
}
impl Drop for Dc {
fn drop(&mut self) {
// SAFETY: the DC came from CreateDCW and is deleted once.
unsafe { DeleteDC(self.0) };
}
}
pub fn page(printer: &str, settings: Option<&DevMode>) -> Result<Page> {
Dc::create(printer, settings)?.page()
}
/// RGB rows top-down → a bottom-up 24-bit DIB (BGR, rows padded to 4 bytes).
fn dib(image: &RgbImage<'_>) -> Result<(BITMAPINFO, Vec<u8>)> {
let (w, h) = (image.width as usize, image.height as usize);
let stride = (w.checked_mul(3).ok_or_else(|| PrintError::Invalid("image too large".into()))? + 3) & !3;
let total = stride.checked_mul(h).filter(|t| *t <= i32::MAX as usize).ok_or_else(|| PrintError::Invalid("image too large to print in one piece".into()))?;
let mut bits = vec![0u8; total];
for (y, src) in image.data.chunks_exact(w * 3).enumerate() {
let start = (h - 1 - y) * stride;
let Some(dst) = bits.get_mut(start..start + w * 3) else { continue };
for (d, s) in dst.as_chunks_mut::<3>().0.iter_mut().zip(src.as_chunks::<3>().0) {
*d = [s[2], s[1], s[0]];
}
}
let header = BITMAPINFOHEADER {
biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
biWidth: image.width as i32,
biHeight: image.height as i32,
biPlanes: 1,
biBitCount: 24,
biCompression: BI_RGB,
biSizeImage: total as u32,
..Default::default()
};
Ok((BITMAPINFO { bmiHeader: header, bmiColors: [Default::default()] }, bits))
}
pub fn print(job: &Job<'_>, image: &RgbImage<'_>, rect: (f64, f64, f64, f64)) -> Result<Printed> {
if image.width > i32::MAX as u32 || image.height > i32::MAX as u32 {
return Err(PrintError::Invalid("image too large".into()));
}
let dc = Dc::create(job.printer, job.settings)?;
let page = dc.page()?;
let (x, y, w, h) = crate::device_rect(&page, rect);
let (bmi, bits) = dib(image)?;
let title = wide(job.title)?;
let output = job.output_file.map(wide).transpose()?;
let info = DOCINFOW {
cbSize: std::mem::size_of::<DOCINFOW>() as i32,
lpszDocName: title.as_ptr(),
lpszOutput: output.as_ref().map_or(null(), |o| o.as_ptr()),
lpszDatatype: null(),
fwType: 0,
};
// SAFETY: a valid printer DC. ICM off: the pixels are already in the printer's space
// (or deliberately unmanaged), so GDI must not convert them again.
unsafe { SetICMMode(dc.0, ICM_OFF) };
// SAFETY: a valid DC; HALFTONE resampling needs the brush origin reset afterwards.
unsafe {
SetStretchBltMode(dc.0, HALFTONE);
SetBrushOrgEx(dc.0, 0, 0, null_mut());
}
// SAFETY: `info` and the strings it points to live until EndDoc/AbortDoc below.
if unsafe { StartDocW(dc.0, &info) } <= 0 {
return Err(last_error("starting the print job"));
}
let fail = |what: &str| {
let e = last_error(what);
// SAFETY: the job was started on this DC.
unsafe { AbortDoc(dc.0) };
e
};
for _ in 0..job.copies.clamp(1, 999) {
// SAFETY: inside a started document on a valid DC.
if unsafe { StartPage(dc.0) } <= 0 {
return Err(fail("starting a page"));
}
// SAFETY: `bits` is a bottom-up DIB exactly as `bmi` describes (24-bit, padded rows,
// `biSizeImage` bytes); the source rectangle is the whole image.
let lines =
unsafe { StretchDIBits(dc.0, x, y, w, h, 0, 0, image.width as i32, image.height as i32, bits.as_ptr().cast(), &bmi, DIB_RGB_COLORS, SRCCOPY) };
if lines <= 0 {
return Err(fail("sending the image to the printer"));
}
// SAFETY: closes the page started above.
if unsafe { EndPage(dc.0) } <= 0 {
return Err(fail("finishing a page"));
}
}
// SAFETY: closes the document started above.
if unsafe { EndDoc(dc.0) } <= 0 {
return Err(last_error("finishing the print job"));
}
Ok(Printed { device_rect: (x, y, w, h), dpi: page.dpi })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dib_is_bottom_up_bgr_with_padded_rows() {
// 2×2: red, green / blue, white.
let data = [255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255];
let (bmi, bits) = dib(&RgbImage { width: 2, height: 2, data: &data }).unwrap();
assert_eq!(bmi.bmiHeader.biHeight, 2);
assert_eq!(bits.len(), 16, "6 bytes per row padded to 8");
// First stored row is the bottom one: blue, white.
assert_eq!(&bits[0..6], &[255, 0, 0, 255, 255, 255]);
assert_eq!(&bits[8..14], &[0, 0, 255, 0, 255, 0]);
}
#[test]
fn settings_from_another_printer_are_not_passed_to_the_driver() {
let mut b = vec![0u8; 220];
for (i, c) in "Printer A".encode_utf16().enumerate() {
b[i * 2..i * 2 + 2].copy_from_slice(&c.to_le_bytes());
}
b[68..70].copy_from_slice(&220u16.to_le_bytes());
let d = DevMode::from_bytes(b).unwrap();
assert!(input_settings("Printer A", Some(&d)).is_some());
assert!(input_settings("Printer B", Some(&d)).is_none());
assert!(input_settings("Printer A", None).is_none());
}
/// A real job through Windows' "Microsoft Print to PDF" driver, written to a file (needs that
/// printer installed; run by hand).
#[test]
#[ignore]
fn prints_through_a_real_driver_to_a_file() {
let out = std::env::temp_dir().join(format!("pc-winprint-{}.pdf", std::process::id()));
let _ = std::fs::remove_file(&out);
let settings = crate::default_settings("Microsoft Print to PDF").unwrap();
let (w, h) = (300u32, 200u32);
let data: Vec<u8> = (0..w * h).flat_map(|i| if (i % w) < w / 2 { [220, 30, 30] } else { [30, 30, 220] }).collect();
let out_s = out.to_string_lossy().into_owned();
let job = Job { printer: "Microsoft Print to PDF", settings: Some(&settings), title: "PhotoCraft test", output_file: Some(&out_s), copies: 2 };
let done = crate::print(&job, &RgbImage { width: w, height: h, data: &data }, (72.0, 72.0, 216.0, 144.0)).unwrap();
assert_eq!(done.device_rect.2, done.dpi.0 as i32 * 3, "3 inches wide");
let pdf = std::fs::read(&out).unwrap();
assert!(pdf.starts_with(b"%PDF"), "the driver wrote a PDF");
assert!(pdf.len() > 1000, "the page has content");
let _ = std::fs::remove_file(&out);
}
/// Lists this machine's printers and their pages (needs real printers; run by hand).
#[test]
#[ignore]
fn lists_real_printers() {
for p in printers().unwrap() {
println!("{}{} {:?}", p.name, if p.default { " (default)" } else { "" }, page(&p.name, None));
}
}
}
+2
View File
@@ -46,6 +46,8 @@ pub const TABLE: &[(&str, Class)] = &[
("adobe-assets", Class::Standalone),
// Pen tablet input (the one isolated `unsafe` helper: AppKit interop on macOS).
("tablet", Class::Standalone),
// Windows printing (an isolated `unsafe` helper: winspool and GDI).
("winprint", Class::Standalone),
("doc", Class::Layer(1)),
("ops", Class::Layer(2)),
("paint", Class::Layer(2)),