Files
OneCAD-Draft/src/io/windows_media.rs
T
PanosandClaude Opus 5 b8472a00b4 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>
2026-09-18 18:42:03 +03:00

313 lines
13 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.
//! 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);
}
}