forked from 1b-agent/opencadstudio-upstream
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:
1 parent
c36ae08fd0
commit
b8472a00b4
27 files changed
+721
-40
No files matched your search
+276
-26
@@ -143,6 +143,47 @@ pub fn printer_report() -> Vec<String> {
|
||||
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()]
|
||||
@@ -564,17 +605,17 @@ fn rotate_rgba90(pixels: &[u8], width: u32, height: u32) -> Vec<u8> {
|
||||
rotated
|
||||
}
|
||||
|
||||
/// Fetch the printer driver's DEVMODE and flip its orientation to match the
|
||||
/// plot's aspect (`landscape` = image wider than tall). The merged DEVMODE is
|
||||
/// returned as raw bytes (drivers append private data after the struct).
|
||||
/// `None` when the driver could not be queried — CreateDC then falls back to
|
||||
/// the driver default.
|
||||
/// 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 oriented_printer_devmode(device_wide: &[u16], landscape: bool) -> Option<Vec<u8>> {
|
||||
use windows_sys::Win32::Graphics::Gdi::{
|
||||
DEVMODEW, DM_IN_BUFFER, DM_ORIENTATION, DM_OUT_BUFFER, DMORIENT_LANDSCAPE,
|
||||
DMORIENT_PORTRAIT,
|
||||
};
|
||||
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,
|
||||
};
|
||||
@@ -614,32 +655,238 @@ fn oriented_printer_devmode(device_wide: &[u16], landscape: bool) -> Option<Vec<
|
||||
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, DM_IN_BUFFER, DM_ORIENTATION, DM_OUT_BUFFER, DM_PAPERLENGTH, DM_PAPERSIZE,
|
||||
DM_PAPERWIDTH, DMORIENT_LANDSCAPE, DMORIENT_PORTRAIT,
|
||||
};
|
||||
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
|
||||
};
|
||||
// Let the driver reconcile fields that depend on orientation
|
||||
// (paper dimensions, imageable area) before we print with it.
|
||||
if DocumentPropertiesW(
|
||||
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
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(buf)
|
||||
})();
|
||||
ClosePrinter(handle);
|
||||
result
|
||||
) > 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,
|
||||
@@ -678,11 +925,14 @@ fn gdi_raster_print(
|
||||
|
||||
let winspool = wide("WINSPOOL");
|
||||
let device_wide = wide(&device);
|
||||
// The dialog's Landscape/Portrait choice rides in through the DEVMODE:
|
||||
// the sheet in the raster decides, and the driver's default must not
|
||||
// override it (a landscape plot into a portrait page used to shrink and
|
||||
// misorient the output).
|
||||
let dm_buf = oriented_printer_devmode(&device_wide, page.width > page.height);
|
||||
// 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>())
|
||||
|
||||
Reference in new issue
Block a user