feat(plot): Windows printer media and printable areas

The plot dialog asks CUPS printers for their sheets and margins; on Windows
the query was a stub, so every printer showed the paper catalogue with
unverified margins. The driver is now asked the way the plot dialog expects:

- windows_media (compiled and tested everywhere): the three parallel
  DeviceCapabilities tables (ids, sizes in tenths of a millimetre, 64-WCHAR
  names) become one PrinterMedia per distinct size — a rotated or
  borderless twin folds onto its sheet, catalogue sheets keep their name,
  unknown sizes carry the driver's — with the device's margins on every
  entry and the driver's default sheet found by id or by user size.
- print_to_printer (Windows): DeviceCapabilitiesW for the tables, the
  default DEVMODE for the default sheet, one portrait printer DC for the
  unprintable margins (physical offsets and extents, per device — a
  documented approximation). The DEVMODE fetch is shared with the print
  path (with_printer_devmode).
- A direct print now asks the driver for the plot's own sheet (by id, or
  as a user-defined size), so an A3 plot prints on A3 rather than shrunk
  into the driver's default; a driver that refuses the sheet is asked for
  the orientation alone.
- The capability cache remembers printers that could not be asked, so a
  slow failing query runs once per session, not on every dialog open.
- PRINTERS <name> reports a printer's sheets, margins and default sheet.

Tests: names (NUL trim, full slot, lossy), media folding and dropping,
default by id / user size / none, DC margins, paper id lookup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
PanosandClaude Opus 5 committed 2026-09-18 18:42:03 +03:00
1 parent c36ae08fd0
commit b8472a00b4
27 files changed
+721 -40

No files matched your search

+312
View File
@@ -0,0 +1,312 @@
//! What a Windows printer driver says about its sheets, turned into the plot
//! dialog's [`PrinterCapabilities`] — the counterpart of the CUPS media
//! parser in `plot_device.rs`.
//!
//! The driver answers three `DeviceCapabilities` questions with parallel
//! arrays (a paper id, a size in tenths of a millimetre and a 64-character
//! name per sheet) and, through its default DEVMODE, which sheet it starts
//! with; the unprintable margins come from a printer DC. Everything here
//! works on those raw arrays so it compiles and is tested on every platform;
//! the calls that fill them live in `print_to_printer.rs`.
use crate::io::paper_catalog::{self, Margins, PaperSize, PaperUnits};
use crate::io::plot_device::{PrinterCapabilities, PrinterMedia};
/// `DC_PAPERNAMES` returns names in fixed-width slots of this many UTF-16
/// units, NUL-padded (and not NUL-terminated when exactly full).
pub const PAPER_NAME_CHARS: usize = 64;
/// `dmPaperSize` of a user-defined sheet: its size is in `dmPaperWidth` /
/// `dmPaperLength` rather than in the driver's table.
pub const DMPAPER_USER: u16 = 256;
/// Sheets a driver would list but cannot mean: a "user defined" slot is
/// reported as 0 × 0, and some drivers pad with absurd extents.
const MAX_SHEET_MM: f64 = 10_000.0;
/// The driver's answers, as the Win32 calls hand them over.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct RawPrinterMedia {
/// `DC_PAPERS`: one `DMPAPER_*` id per sheet.
pub ids: Vec<u16>,
/// `DC_PAPERSIZE`: one `(width, height)` per sheet, tenths of a millimetre.
pub sizes_tenths_mm: Vec<(i32, i32)>,
/// `DC_PAPERNAMES`: `count × PAPER_NAME_CHARS` UTF-16 units, flat.
pub names_utf16: Vec<u16>,
/// The default DEVMODE's `dmPaperSize` when `DM_PAPERSIZE` is set.
pub default_id: Option<u16>,
/// The default DEVMODE's `dmPaperWidth` / `dmPaperLength` (tenths of a
/// millimetre) for a `DMPAPER_USER` default.
pub default_tenths_mm: Option<(i32, i32)>,
/// Unprintable margins measured on the printer DC, portrait, mm.
pub margins: Option<Margins>,
}
/// The `GetDeviceCaps` values a printable-area calculation needs, taken
/// from one printer DC in portrait.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct DeviceCapsSample {
/// `PHYSICALWIDTH` / `PHYSICALHEIGHT`: the sheet, in device pixels.
pub physical_width: i32,
pub physical_height: i32,
/// `PHYSICALOFFSETX` / `PHYSICALOFFSETY`: the printable area's corner.
pub offset_x: i32,
pub offset_y: i32,
/// `HORZRES` / `VERTRES`: the printable area, in device pixels.
pub horz_res: i32,
pub vert_res: i32,
/// `LOGPIXELSX` / `LOGPIXELSY`: device pixels per inch.
pub dpi_x: i32,
pub dpi_y: i32,
}
/// The sheet names out of a `DC_PAPERNAMES` buffer: each slot is trimmed at
/// its first NUL (a name that fills its slot has none) and decoded lossily.
pub fn paper_names(names_utf16: &[u16], count: usize) -> Vec<String> {
names_utf16
.chunks(PAPER_NAME_CHARS)
.take(count)
.map(|slot| {
let end = slot.iter().position(|&unit| unit == 0).unwrap_or(slot.len());
String::from_utf16_lossy(&slot[..end]).trim().to_string()
})
.collect()
}
/// The unprintable margins a DC sample describes, in millimetres — `None`
/// when the driver reported no usable resolution or extents.
pub fn device_margins_mm(sample: &DeviceCapsSample) -> Option<Margins> {
if sample.dpi_x <= 0
|| sample.dpi_y <= 0
|| sample.physical_width <= 0
|| sample.physical_height <= 0
|| sample.horz_res <= 0
|| sample.vert_res <= 0
{
return None;
}
let mm_x = |pixels: i32| f64::from(pixels) / f64::from(sample.dpi_x) * 25.4;
let mm_y = |pixels: i32| f64::from(pixels) / f64::from(sample.dpi_y) * 25.4;
let margins = Margins {
left: mm_x(sample.offset_x),
top: mm_y(sample.offset_y),
right: mm_x(sample.physical_width - sample.offset_x - sample.horz_res),
bottom: mm_y(sample.physical_height - sample.offset_y - sample.vert_res),
};
let sane = |value: f64| value.is_finite() && value >= 0.0;
(sane(margins.left) && sane(margins.top) && sane(margins.right) && sane(margins.bottom))
.then_some(margins)
}
/// The driver's sheets as the plot dialog lists them: one entry per distinct
/// size (a rotated or borderless twin folds onto its sheet), catalogue
/// sheets under their own name, unknown sizes under the driver's name, the
/// device's margins on every entry, and the driver's default sheet when it
/// is one of them. `None` when the driver listed nothing usable, so the
/// dialog falls back to the catalogue.
pub fn printer_media(raw: &RawPrinterMedia) -> Option<PrinterCapabilities> {
let count = raw.ids.len().min(raw.sizes_tenths_mm.len());
let names = paper_names(&raw.names_utf16, count);
let margins = raw.margins.unwrap_or(Margins::ZERO);
let mut media: Vec<PrinterMedia> = Vec::new();
// Which listed sheet each id resolves to, so the default can be found by
// id even when its own entry folded onto an earlier twin.
let mut sheet_of_id: Vec<(u16, usize)> = Vec::new();
for index in 0..count {
let (w, h) = raw.sizes_tenths_mm[index];
let (w, h) = (f64::from(w) / 10.0, f64::from(h) / 10.0);
if !(w > 0.0 && h > 0.0 && w <= MAX_SHEET_MM && h <= MAX_SHEET_MM) {
continue;
}
let (pw, ph) = if w <= h { (w, h) } else { (h, w) };
let name = names.get(index).cloned().unwrap_or_default();
if let Some(position) = media.iter().position(|m| same_size(&m.paper, pw, ph)) {
if name.to_ascii_lowercase().contains("borderless") {
media[position].borderless = true;
}
sheet_of_id.push((raw.ids[index], position));
continue;
}
let paper = paper_catalog::match_dimensions_mm(pw, ph).cloned().unwrap_or_else(|| {
if name.is_empty() {
PaperSize::custom(pw, ph, PaperUnits::Millimeters)
} else {
PaperSize::custom_named(&name, pw, ph, PaperUnits::Millimeters)
}
});
sheet_of_id.push((raw.ids[index], media.len()));
media.push(PrinterMedia {
paper,
margins,
borderless: name.to_ascii_lowercase().contains("borderless"),
});
}
if media.is_empty() {
return None;
}
let default_paper = match raw.default_id {
Some(DMPAPER_USER) => raw.default_tenths_mm.and_then(|(w, h)| {
let (w, h) = (f64::from(w) / 10.0, f64::from(h) / 10.0);
let (pw, ph) = if w <= h { (w, h) } else { (h, w) };
media
.iter()
.find(|m| same_size(&m.paper, pw, ph))
.map(|m| m.paper.clone())
}),
Some(id) => sheet_of_id
.iter()
.find(|(candidate, _)| *candidate == id)
.map(|(_, position)| media[*position].paper.clone()),
None => None,
};
Some(PrinterCapabilities {
media,
default_paper,
})
}
/// The driver's paper id for a sheet of this size in either orientation —
/// what a print job's DEVMODE needs so the sheet is printed at its own size
/// rather than on the driver's default.
pub fn paper_id_for_sheet(raw: &RawPrinterMedia, width_mm: f64, height_mm: f64) -> Option<u16> {
let (pw, ph) = if width_mm <= height_mm {
(width_mm, height_mm)
} else {
(height_mm, width_mm)
};
raw.ids
.iter()
.zip(&raw.sizes_tenths_mm)
.find(|(_, (w, h))| {
let (w, h) = (f64::from(*w) / 10.0, f64::from(*h) / 10.0);
let (sw, sh) = if w <= h { (w, h) } else { (h, w) };
(sw - pw).abs() <= 0.5 && (sh - ph).abs() <= 0.5
})
.map(|(id, _)| *id)
}
fn same_size(paper: &PaperSize, portrait_w: f64, portrait_h: f64) -> bool {
let (w, h) = paper.portrait_mm();
(w - portrait_w).abs() <= 0.5 && (h - portrait_h).abs() <= 0.5
}
#[cfg(test)]
mod tests {
use super::*;
fn slot(name: &str) -> Vec<u16> {
let mut units: Vec<u16> = name.encode_utf16().collect();
units.resize(PAPER_NAME_CHARS, 0);
units
}
fn raw(entries: &[(u16, (i32, i32), &str)]) -> RawPrinterMedia {
RawPrinterMedia {
ids: entries.iter().map(|(id, _, _)| *id).collect(),
sizes_tenths_mm: entries.iter().map(|(_, size, _)| *size).collect(),
names_utf16: entries.iter().flat_map(|(_, _, name)| slot(name)).collect(),
default_id: None,
default_tenths_mm: None,
margins: Some(Margins {
left: 3.0,
bottom: 3.0,
right: 3.0,
top: 3.0,
}),
}
}
#[test]
fn names_are_trimmed_at_the_first_nul_and_survive_a_full_slot() {
let mut buffer = slot("A4");
buffer.extend(std::iter::repeat_n(b'x' as u16, PAPER_NAME_CHARS));
// A lone surrogate decodes lossily instead of failing.
let mut broken = slot("Letter");
broken[6] = 0xD800;
broken[7] = 0;
buffer.extend(broken);
let names = paper_names(&buffer, 3);
assert_eq!(names[0], "A4");
assert_eq!(names[1].len(), PAPER_NAME_CHARS);
assert!(names[2].starts_with("Letter"));
assert_eq!(paper_names(&buffer, 1).len(), 1, "count caps the slots read");
}
#[test]
fn twins_fold_onto_their_sheet_and_nonsense_sizes_are_dropped() {
let caps = printer_media(&raw(&[
(9, (2100, 2970), "A4"),
(77, (2970, 2100), "A4 Rotated"),
(1, (2159, 2794), "Letter"),
(256, (0, 0), "User Defined"),
(300, (2100, 2970), "A4 Borderless"),
(301, (1000, 1500), "Photo 10x15"),
]))
.expect("usable sheets");
assert_eq!(caps.media.len(), 3);
assert_eq!(caps.media[0].paper.canonical, "ISO_A4_(210.00_x_297.00_MM)");
assert!(caps.media[0].borderless, "the borderless twin marks the sheet");
assert_eq!(caps.media[1].paper.canonical, "ANSI_A_(8.50_x_11.00_Inches)");
assert_eq!(caps.media[2].paper.label, "Photo 10x15", "the driver's name is kept");
assert_eq!(caps.media[2].margins.left, 3.0, "the device margins go on every sheet");
assert!(caps.default_paper.is_none());
}
#[test]
fn the_default_sheet_is_found_by_id_or_by_user_size() {
let mut media = raw(&[
(9, (2100, 2970), "A4"),
(77, (2970, 2100), "A4 Rotated"),
(8, (2970, 4200), "A3"),
]);
media.default_id = Some(77);
let caps = printer_media(&media).unwrap();
assert_eq!(
caps.default_paper.as_ref().map(|p| p.canonical.as_ref()),
Some("ISO_A4_(210.00_x_297.00_MM)"),
"the rotated twin's id still names A4"
);
media.default_id = Some(DMPAPER_USER);
media.default_tenths_mm = Some((4200, 2970));
let caps = printer_media(&media).unwrap();
assert_eq!(
caps.default_paper.as_ref().map(|p| p.canonical.as_ref()),
Some("ISO_A3_(297.00_x_420.00_MM)")
);
media.default_id = Some(999);
assert!(printer_media(&media).unwrap().default_paper.is_none());
assert!(printer_media(&RawPrinterMedia::default()).is_none(), "nothing listed → catalogue");
}
#[test]
fn device_margins_come_from_the_dc_geometry() {
let sample = DeviceCapsSample {
physical_width: 4960,
physical_height: 7016,
offset_x: 100,
offset_y: 100,
horz_res: 4760,
vert_res: 6716,
dpi_x: 600,
dpi_y: 600,
};
let margins = device_margins_mm(&sample).unwrap();
let close = |a: f64, b: f64| (a - b).abs() < 1e-6;
assert!(close(margins.left, 100.0 / 600.0 * 25.4));
assert!(close(margins.right, 100.0 / 600.0 * 25.4));
assert!(close(margins.top, 100.0 / 600.0 * 25.4));
assert!(close(margins.bottom, 200.0 / 600.0 * 25.4));
assert!(device_margins_mm(&DeviceCapsSample::default()).is_none());
let inverted = DeviceCapsSample { horz_res: 9000, ..sample };
assert!(device_margins_mm(&inverted).is_none(), "printable area wider than the sheet");
}
#[test]
fn the_paper_id_matches_either_orientation_within_half_a_millimetre() {
let media = raw(&[(9, (2100, 2970), "A4"), (8, (2970, 4200), "A3")]);
assert_eq!(paper_id_for_sheet(&media, 297.0, 210.0), Some(9));
assert_eq!(paper_id_for_sheet(&media, 210.3, 297.4), Some(9));
assert_eq!(paper_id_for_sheet(&media, 420.0, 297.0), Some(8));
assert_eq!(paper_id_for_sheet(&media, 216.0, 279.0), None);
}
}