diff --git a/crates/automation/src/printing.rs b/crates/automation/src/printing.rs index 9a1c1a6b7c..fef5beaaab 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -202,7 +202,8 @@ impl Automation { }; let settings = print::Settings { pages, paper, orientation, layout, content, raster, crops: Vec::new() }; let sizes: Vec<(f64, f64)> = self.doc(a)?.info.pages.iter().map(|p| (p.width as f64, p.height as f64)).collect(); - let sheets = print::layout(&sizes, &settings).map_err(|e| bad(e.to_string()))?.len(); + let laid_out = print::layout(&sizes, &settings).map_err(|e| bad(e.to_string()))?; + let sheets = laid_out.len(); let bytes = self.session.print_pdf(id, &settings).map_err(failed)?; let mut out = json!({ "sheets": sheets, "pages": settings.pages.len(), "bytes": bytes.len() }); match (a.opt_str("path")?, a.opt_str("printer")?) { @@ -212,7 +213,12 @@ impl Automation { out["path"] = json!(path.to_string_lossy()); } (None, Some(printer)) => { - let job = spool_job(a, printer, self.doc(a)?.name.clone(), duplex, paper_index)?; + let mut job = spool_job(a, printer, self.doc(a)?.name.clone(), duplex, paper_index)?; + // CUPS is told which way the sheets run, as the Print dialog does (#2713); Windows' + // print ticket already is. An orientation the caller chose in `options` stands. + if !cfg!(windows) && !job.options.iter().any(|(k, _)| k == "orientation-requested") { + job.options.extend(print::cups_orientation(&laid_out)); + } out["job"] = json!(spool::submit(&bytes, &job).map_err(|e| failed(e.to_string()))?); } _ => return Err(bad("pass either path (save the print-ready PDF) or printer (a name from printers, or \"default\")")), diff --git a/crates/print/README.md b/crates/print/README.md index 1379be2b42..7f9f23b3ab 100644 --- a/crates/print/README.md +++ b/crates/print/README.md @@ -46,7 +46,9 @@ Headless `doc_print` defaults are unchanged. `spool` delegates discovery, driver options, preferences and submission to [`craft-print`](https://github.com/storytold/craft-libs/tree/main/crates/print). PdfCraft retains page selection, imposition, annotations and print-permission checks. -CUPS jobs stream through stdin with `fit-to-page=false`. Windows renders sheets through +CUPS jobs stream through stdin with `fit-to-page=false`, and with `orientation-requested=4` when +every sheet is landscape (`cups_orientation`), since CUPS would otherwise shrink a landscape +sheet into portrait paper. Windows renders sheets through `Windows.Data.Pdf` and spools them at their physical PDF size with per-page paper and orientation, zero added margins and hardware-edge clipping. Long-edge and short-edge duplex use the corresponding Windows binding edges. Random, exclusively created Windows job files diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index b731aff00f..b32cb15541 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -361,6 +361,18 @@ pub fn media_option(index: usize) -> Option<(String, String)> { cups_media(index).map(|m| ("media".to_string(), m.to_string())) } +/// The CUPS job option that keeps landscape sheets landscape (`orientation-requested` 4). +/// +/// The print-ready PDF's sheets are already laid out on their paper, but the paper is named +/// portrait (`Letter`), and without this option CUPS leaves a landscape sheet unrotated and +/// shrinks it into the portrait paper instead of turning it to fill it (#2713). It is a property +/// of the whole job, so it is given only when *every* sheet is landscape; a job that mixes +/// orientations is left to the spooler, as portrait sheets would be turned wrongly. +pub fn cups_orientation(sheets: &[Sheet]) -> Option<(String, String)> { + let landscape = !sheets.is_empty() && sheets.iter().all(|s| s.size.0 > s.size.1); + landscape.then(|| ("orientation-requested".to_string(), "4".to_string())) +} + /// Whether most of page `index`'s text runs bottom-to-top as the page displays (a label laid /// sideways on a letter or A4 sheet), counting each text-showing operator once; form XObjects /// are followed a few levels deep. diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index e6432d8aec..c37abdf8fc 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -509,6 +509,28 @@ fn printer_option_defaults_and_job_arguments() { assert!(spool::printer_options("../../etc/passwd").is_empty(), "not a queue name"); } +#[test] +fn landscape_sheets_ask_cups_to_turn_the_page() { + let landscape = |size: (f64, f64)| Sheet { size, ..Sheet::default() }; + let ask = Some(("orientation-requested".to_string(), "4".to_string())); + // A landscape Size sheet, and a booklet (always landscape sheets), are both turned. + let page = layout(&[(300.0, 200.0)], &settings(vec![0], Layout::Size(SizeMode::Fit))).unwrap(); + assert_eq!(cups_orientation(&page), ask); + let booklet = + layout(&[(200.0, 300.0); 4], &settings(vec![0, 1, 2, 3], Layout::Booklet { subset: BookletSubset::BothSides, binding: Binding::Left })) + .unwrap(); + assert_eq!(cups_orientation(&booklet), ask); + // Portrait sheets, mixed jobs and nothing at all say nothing. + let portrait = layout(&[(200.0, 300.0)], &settings(vec![0], Layout::Size(SizeMode::Fit))).unwrap(); + assert_eq!(cups_orientation(&portrait), None); + assert_eq!(cups_orientation(&[landscape((792.0, 612.0)), landscape((612.0, 792.0))]), None, "a mixed job is not turned as a whole"); + assert_eq!(cups_orientation(&[landscape((612.0, 612.0))]), None, "a square sheet has no orientation"); + assert_eq!(cups_orientation(&[]), None); + // `lp` is given it, among the job's other options. + let job = Job { options: ask.into_iter().collect(), ..Job::default() }; + assert!(lp_args(&job).join(" ").ends_with("-o fit-to-page=false -o orientation-requested=4")); +} + #[cfg(all(unix, not(target_arch = "wasm32")))] mod cups_options { use crate::spool; diff --git a/crates/ui-egui/src/print_ui.rs b/crates/ui-egui/src/print_ui.rs index 5bf8737fcc..2a68927f26 100644 --- a/crates/ui-egui/src/print_ui.rs +++ b/crates/ui-egui/src/print_ui.rs @@ -653,7 +653,15 @@ impl PdfCraftApp { } } } - Some(printer) => self.send_to_printer(printer, bytes, self.print_draft.job(&name)), + Some(printer) => { + let mut job = self.print_draft.job(&name); + // CUPS is told which way the sheets run; Windows' print ticket already is. + if !cfg!(windows) { + let sizes: Vec<(f64, f64)> = doc.info.pages.iter().map(|p| (f64::from(p.width), f64::from(p.height))).collect(); + job.options.extend(print::layout(&sizes, &settings).ok().and_then(|sheets| print::cups_orientation(&sheets))); + } + self.send_to_printer(printer, bytes, job) + } None => self.save_print_pdf(&name, bytes), } } diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 5e1c4a7e75..a042c2d642 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -8144,8 +8144,8 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print"] -evidence = ["crates/print/src/tests.rs::size_modes", "crates/print/src/tests.rs::architectural_sheets_preserve_full_scale", "crates/automation/tests/it/automation.rs::architectural_and_iso_papers_through_tools", "crates/ui-egui/tests/it/print.rs::architectural_page_can_be_matched_at_100_percent"] -notes = "auto portrait/landscape" +evidence = ["crates/print/src/tests.rs::size_modes", "crates/print/src/tests.rs::architectural_sheets_preserve_full_scale", "crates/automation/tests/it/automation.rs::architectural_and_iso_papers_through_tools", "crates/ui-egui/tests/it/print.rs::architectural_page_can_be_matched_at_100_percent", "crates/print/src/tests.rs::landscape_sheets_ask_cups_to_turn_the_page"] +notes = "auto portrait/landscape. CUPS jobs carry orientation-requested=4 when every sheet is landscape, so a landscape sheet fills its portrait paper instead of being shrunk into it (#2713); a job that mixes orientations is left to the spooler, and Windows sets orientation in its own print ticket. The CUPS option is checked against Apple's filters and one physical printer, not a test that runs a spooler." [[feature]] id = "print.comments-and-forms"