Merge PR #7847: Print: Portrait and Landscape in the Print dialog on Windows

This commit is contained in:
aiongg
2026-10-20 18:39:29 +00:00
4 changed files with 273 additions and 7 deletions
+81 -7
View File
@@ -708,6 +708,61 @@ fn printer_settings(p: &Value) -> Result<Option<craft_print::rgb::DevMode>> {
}
}
/// `dmFields` and `dmOrientation` in DEVMODEW (wingdi.h: after the 32-wchar device name and four
/// header words), and the `dmFields` bit that says `dmOrientation` is set.
const DM_FIELDS_AT: usize = 72;
const DM_ORIENTATION_AT: usize = 76;
const DM_ORIENTATION: u32 = 0x1;
/// `DMORIENT_PORTRAIT` and `DMORIENT_LANDSCAPE`.
const DMORIENT_PORTRAIT: i16 = 1;
const DMORIENT_LANDSCAPE: i16 = 2;
/// Driver settings turned to portrait or landscape, as Photoshop's Layout buttons turn them:
/// `dmOrientation` set and flagged in `dmFields`, every other byte (the driver's private settings
/// included) kept. The driver validates the result when it lays out the page, as it does for
/// settings from Print Settings….
pub fn turned_settings(settings: &craft_print::rgb::DevMode, landscape: bool) -> Result<craft_print::rgb::DevMode> {
let mut b = settings.as_bytes().to_vec();
let short = || other("printer settings are too short to hold an orientation");
let fields: [u8; 4] = b.get(DM_FIELDS_AT..DM_FIELDS_AT + 4).and_then(|s| s.try_into().ok()).ok_or_else(short)?;
let fields = u32::from_le_bytes(fields) | DM_ORIENTATION;
b.get_mut(DM_FIELDS_AT..DM_FIELDS_AT + 4).ok_or_else(short)?.copy_from_slice(&fields.to_le_bytes());
let orientation = if landscape { DMORIENT_LANDSCAPE } else { DMORIENT_PORTRAIT };
b.get_mut(DM_ORIENTATION_AT..DM_ORIENTATION_AT + 2).ok_or_else(short)?.copy_from_slice(&orientation.to_le_bytes());
craft_print::rgb::DevMode::from_bytes(b).map_err(other)
}
/// Whether a driver page of `driver` points is the layout's `paper` turned the other way round
/// (within the 2 pt the driver and layout may differ by): then turning the driver settings, not
/// choosing another paper, makes them agree. A square paper is never turned.
fn turned_paper(driver: (f64, f64), paper: (f64, f64)) -> bool {
let near = |a: f64, b: f64| (a - b).abs() <= 2.0;
let same = near(driver.0, paper.0) && near(driver.1, paper.1);
!same && near(driver.0, paper.1) && near(driver.1, paper.0)
}
/// The driver settings and page for printing a layout of `paper` points on `printer`: the given
/// settings (or the printer's own) as they are, or turned to the layout's orientation when the
/// driver has that paper the other way round. A landscape layout so prints landscape with portrait
/// Print Settings, as a script or another app asking for landscape expects.
fn settings_for_layout(
printer: &str,
settings: Option<craft_print::rgb::DevMode>,
paper: (f64, f64),
) -> Result<(Option<craft_print::rgb::DevMode>, craft_print::rgb::Page)> {
let page = craft_print::rgb::page(printer, settings.as_ref()).map_err(other)?;
if !turned_paper(page.paper, paper) {
return Ok((settings, page));
}
let base = match settings {
Some(s) => s,
None => craft_print::rgb::default_settings(printer).map_err(other)?,
};
let turned = turned_settings(&base, paper.0 > paper.1)?;
let page = craft_print::rgb::page(printer, Some(&turned)).map_err(other)?;
Ok((Some(turned), page))
}
/// 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;
@@ -742,8 +797,7 @@ pub fn page_rect_top_left(page: &PrintPage) -> (f64, f64, f64, f64) {
/// PDF-only (no font/text layout in the GDI path yet).
fn print_windows(page: &PrintPage, p: &Value, title: &str, copies: u32) -> Result<Value> {
let printer = printer_name(p)?;
let settings = printer_settings(p)?;
let device_page = craft_print::rgb::page(&printer, settings.as_ref()).map_err(other)?;
let (settings, device_page) = settings_for_layout(&printer, printer_settings(p)?, page.paper)?;
if (device_page.paper.0 - page.paper.0).abs() > 2.0 || (device_page.paper.1 - page.paper.1).abs() > 2.0 {
return Err(other("the driver paper or orientation differs from the print layout; choose the matching paper in Print Settings before printing"));
}
@@ -780,11 +834,31 @@ fn printers(_: &mut Session, _: &Value) -> Result<Value> {
}
fn printer_page(_: &mut Session, p: &Value) -> Result<Value> {
// Checked before the printer, so a bad value is the error whether or not a printer answers.
let landscape = match p.get("orientation").filter(|v| !v.is_null()) {
None => None,
Some(v) => match v.as_str() {
Some("portrait") => Some(false),
Some("landscape") => Some(true),
_ => return Err(bad("file.print.page", "orientation is \"portrait\" or \"landscape\"")),
},
};
let printer = printer_name(p)?;
let pg = craft_print::rgb::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]}),
)
let mut settings = printer_settings(p)?;
if let Some(landscape) = landscape {
let base = match settings {
Some(s) => s,
None => craft_print::rgb::default_settings(&printer).map_err(other)?,
};
settings = Some(turned_settings(&base, landscape)?);
}
let pg = craft_print::rgb::page(&printer, settings.as_ref()).map_err(other)?;
let mut out = 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]});
// The turned settings, to pass on as `printerSettings` (the Print dialog's Orientation does).
if let (Some(_), Some(s), Some(o)) = (landscape, settings, out.as_object_mut()) {
o.insert("printerSettings".into(), json!(s.to_hex()));
}
Ok(out)
}
/// Set once by test harnesses (the panic hunt) so no command in the process can reach a printer.
@@ -1014,7 +1088,7 @@ pub fn specs() -> Vec<CommandSpec> {
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)"##,
params: r##"{"printer":name? (default printer),"printerSettings":hex?,"orientation":"portrait|landscape"? (turn the settings, or the printer's own, that way)} → {printer, paper: [w,h] pt, printable: [x,y,w,h] pt from the top-left, dpi: [x,y], printerSettings: hex (only with orientation: the turned settings)} (Windows)"##,
enabled: |_| Ok(()),
journal: false,
run: printer_page,
+73
View File
@@ -207,6 +207,79 @@ fn printer_commands_answer_without_a_document_and_reject_bad_settings() {
assert!(s.execute("file.print", json!({"printer": "Anything", "printerSettings": "0102", "send": true})).is_err());
}
/// Synthetic driver settings: a DEVMODEW of `size` bytes plus `extra` private driver bytes, every
/// byte a pattern so a stray write shows, with `dmFields` saying only the paper size is set.
fn devmode(size: u16, extra: u16) -> Vec<u8> {
let mut b: Vec<u8> = (0..usize::from(size) + usize::from(extra)).map(|i| (i % 251) as u8).collect();
b[68..70].copy_from_slice(&size.to_le_bytes());
b[70..72].copy_from_slice(&extra.to_le_bytes());
b[72..76].copy_from_slice(&0x2u32.to_le_bytes()); // DM_PAPERSIZE
b
}
/// Photoshop's Layout buttons: driver settings turned to landscape or portrait set `dmOrientation`
/// and its `dmFields` bit, and keep every other byte, the driver's private settings included.
#[test]
fn turned_settings_set_the_orientation_and_keep_the_rest() {
for (size, extra) in [(220u16, 0u16), (220, 48)] {
let before = devmode(size, extra);
let dm = craft_print::rgb::DevMode::from_bytes(before.clone()).unwrap();
for (landscape, orient) in [(true, 2i16), (false, 1i16)] {
let after = turned_settings(&dm, landscape).unwrap().as_bytes().to_vec();
assert_eq!(after.len(), before.len());
assert_eq!(i16::from_le_bytes([after[76], after[77]]), orient, "{size}+{extra} landscape={landscape}");
assert_eq!(u32::from_le_bytes([after[72], after[73], after[74], after[75]]), 0x3, "DM_ORIENTATION added, DM_PAPERSIZE kept");
for (i, (a, b)) in after.iter().zip(&before).enumerate() {
if !(72..76).contains(&i) && !(76..78).contains(&i) {
assert_eq!(a, b, "byte {i} changed");
}
}
}
}
}
/// The driver's paper is turned only when it is the layout's paper the other way round (within
/// the 2 pt a driver's paper may differ by): not for the same paper, another paper or a square one.
#[test]
fn only_the_same_paper_the_other_way_round_is_turned() {
let (a3, a3_wide) = ((842.29, 1191.14), (1190.55, 841.89));
assert!(turned_paper(a3, a3_wide), "portrait driver, landscape layout");
assert!(turned_paper((1191.14, 842.29), (841.89, 1190.55)), "landscape driver, portrait layout");
assert!(!turned_paper(a3, (841.89, 1190.55)), "same orientation");
assert!(!turned_paper((595.32, 841.92), a3_wide), "an A4 driver for an A3 layout");
assert!(!turned_paper((612.0, 612.0), (612.0, 612.0)), "square");
}
/// `file.print.page` takes an orientation and refuses any other value before asking a printer, so
/// the error is the same with or without one.
#[test]
fn printer_page_refuses_an_unknown_orientation() {
let mut s = Session::new();
for bad in [json!("sideways"), json!(2), json!(true)] {
let e = s.execute("file.print.page", json!({"printer": "Anything", "orientation": bad})).unwrap_err().to_string();
assert!(e.contains("orientation"), "{bad}: {e}");
}
}
/// A real driver turns: Windows' "Microsoft Print to PDF" on its default (portrait) settings, for a
/// landscape A4 layout, gets settings turned to landscape and a wide page; a portrait layout keeps
/// the driver's own settings. Needs that printer, so it runs by hand:
/// `cargo test -p photocraft-engine --lib a_portrait_driver -- --ignored`.
#[cfg(windows)]
#[test]
#[ignore]
fn a_portrait_driver_turns_for_a_landscape_layout() {
let printer = "Microsoft Print to PDF";
let defaults = craft_print::rgb::default_settings(printer).unwrap();
let a4 = craft_print::rgb::page(printer, Some(&defaults)).unwrap().paper;
let (settings, page) = settings_for_layout(printer, None, (a4.1, a4.0)).unwrap();
assert!(settings.is_some(), "turned settings come back");
assert!((page.paper.0 - a4.1).abs() <= 2.0 && (page.paper.1 - a4.0).abs() <= 2.0, "wide: {:?}", page.paper);
let (settings, page) = settings_for_layout(printer, None, a4).unwrap();
assert!(settings.is_none(), "nothing to turn");
assert!((page.paper.0 - a4.0).abs() <= 2.0, "{:?}", page.paper);
}
#[test]
fn printer_profile_can_be_an_installed_icc_file() {
let dir = tmp("icc");
+108
View File
@@ -772,6 +772,44 @@ pub fn open_print(app: &mut PhotocraftApp) -> u64 {
app.ui.open_dialog(DialogKind::Command, f)
}
/// Whether the driver's paper, as `driver_page` took it, is wider than tall: the Orientation
/// buttons show landscape then.
fn driver_paper_is_wide(f: &Map<String, Value>) -> bool {
let side = |i: usize| f.get("paper").and_then(Value::as_array).and_then(|a| a.get(i)).and_then(Value::as_f64);
matches!((side(0), side(1)), (Some(w), Some(h)) if w > h)
}
/// The Orientation buttons: the driver settings (or the printer's own) turned portrait or
/// landscape through `file.print.page`. An error stays on the dialog, with the settings left as
/// they were.
fn turn_driver(app: &mut PhotocraftApp, f: &mut Map<String, Value>, landscape: bool) {
let mut q = json!({"printer": s(f, "printer", ""), "orientation": if landscape { "landscape" } else { "portrait" }});
if let (Some(h), Some(o)) = (f.get("printerSettings").cloned(), q.as_object_mut()) {
o.insert("printerSettings".into(), h);
}
match app.session.execute("file.print.page", q) {
Ok(r) => take_turned_page(f, &r),
Err(e) => {
f.insert("__pageError".into(), json!(e.to_string()));
}
}
}
/// The turned settings and the paper they give, both from the one `file.print.page` answer, as
/// `driver_page` takes a page: asking the driver again could fail and leave new settings with the
/// old paper. An answer without both leaves the dialog as it was, with the reason shown.
fn take_turned_page(f: &mut Map<String, Value>, r: &Value) {
let paper = r.get("paper").and_then(Value::as_array).and_then(|a| Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?)));
let (Some(settings), Some((w, h))) = (r.get("printerSettings").and_then(Value::as_str), paper) else {
f.insert("__pageError".into(), json!("the printer did not report the turned page"));
return;
};
f.insert("printerSettings".into(), json!(settings));
f.insert("paper".into(), json!([w, h]));
f.insert("orientation".into(), json!("portrait"));
f.remove("__pageError");
}
/// 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", "");
@@ -1006,6 +1044,17 @@ fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Va
}
}
});
// Photoshop's Layout: Portrait / Landscape turn the driver settings, so the paper
// and the preview turn with them without opening Print Settings….
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!("Orientation")).color(t.text_dim));
let wide = driver_paper_is_wide(f);
for (landscape, label) in [(false, "Portrait"), (true, "Landscape")] {
if crate::widgets::pill_tab(ui, label, wide == landscape).clicked() && wide != landscape {
turn_driver(app, f, landscape);
}
}
});
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…")),
@@ -1530,6 +1579,65 @@ mod tests {
std::fs::remove_file(out).unwrap();
}
}
/// The Orientation buttons show the driver paper's shape: landscape when it is wider than tall.
#[test]
fn orientation_follows_the_driver_paper() {
let with = |paper: Value| {
let mut f = Map::new();
f.insert("paper".into(), paper);
f
};
assert!(driver_paper_is_wide(&with(json!([1191.14, 842.29]))));
assert!(!driver_paper_is_wide(&with(json!([842.29, 1191.14]))));
assert!(!driver_paper_is_wide(&with(json!("a3"))), "a named paper is not a driver page");
assert!(!driver_paper_is_wide(&Map::new()));
}
/// Turning a printer whose driver can't be asked keeps its settings and says why on the dialog.
#[test]
fn turning_without_a_driver_keeps_the_settings_and_shows_the_error() {
// Valid driver settings (a minimal DEVMODEW: 220 bytes, no private part), so the command
// gets past reading them and fails where it asks the (missing) printer for its page.
let mut dm = vec![0u8; 220];
dm[68..70].copy_from_slice(&220u16.to_le_bytes());
let hex: String = dm.iter().map(|b| format!("{b:02x}")).collect();
assert!(craft_print::rgb::DevMode::from_hex(&hex).is_ok());
let (mut app, _) = app();
let mut f = Map::new();
f.insert("printer".into(), json!("photocraft-no-such-printer"));
f.insert("printerSettings".into(), json!(hex));
f.insert("paper".into(), json!([595.28, 841.89]));
turn_driver(&mut app, &mut f, true);
assert_eq!(f.get("printerSettings"), Some(&json!(hex)));
assert_eq!(f.get("paper"), Some(&json!([595.28, 841.89])));
let error = f.get("__pageError").and_then(Value::as_str).unwrap_or_default();
// Reading bad settings fails with "printer settings are too short / damaged"; this must not.
assert!(!error.is_empty() && !error.contains("printer settings are"), "failed at the printer, not at reading the settings: {error}");
}
/// A turn takes the settings and the paper from the same answer, never one without the other.
#[test]
fn a_turn_takes_settings_and_paper_together() {
let fields = || {
let mut f = Map::new();
f.insert("printerSettings".into(), json!("old"));
f.insert("paper".into(), json!([595.28, 841.89]));
f.insert("__pageError".into(), json!("earlier"));
f
};
let mut f = fields();
take_turned_page(&mut f, &json!({"printerSettings": "new", "paper": [841.89, 595.28]}));
assert_eq!((f.get("printerSettings"), f.get("paper")), (Some(&json!("new")), Some(&json!([841.89, 595.28]))));
assert_eq!(f.get("orientation"), Some(&json!("portrait")), "the driver paper is already turned");
assert!(f.get("__pageError").is_none());
for partial in [json!({"printerSettings": "new"}), json!({"paper": [841.89, 595.28]}), json!({"printerSettings": "new", "paper": [841.89]})] {
let mut f = fields();
take_turned_page(&mut f, &partial);
assert_eq!((f.get("printerSettings"), f.get("paper")), (Some(&json!("old")), Some(&json!([595.28, 841.89]))), "{partial}");
assert!(f.get("__pageError").is_some(), "{partial}");
}
}
}
#[cfg(test)]
+11
View File
@@ -38,6 +38,17 @@ with a `note` when no server answers. `"printServer"` on `file.print` sends one
server; `"dryRun": true` renders the page and reports the equivalent `lp` command without
spooling.
## Orientation (Windows)
On Windows the paper and its orientation are the driver's: the Print dialog takes them from the
printer's settings. Its Orientation buttons (Portrait, Landscape; Photoshop's Layout) turn those
settings, as Print Settings… would, and the paper and preview turn with them.
`file.print.page {"orientation": "portrait|landscape"}` does the same for scripts: it returns the
turned settings as `printerSettings`, with the page they give. A print sent to a Windows printer
whose layout is the driver's paper the other way round (a landscape layout, portrait settings)
turns the settings to the layout before printing, rather than stopping with "the driver paper or
orientation differs"; another paper is still refused.
## Results
`file.print` returns `sent`, `backend` (`lp`, `windows`, `ipp`, `portal`), `spooler` (where the