diff --git a/crates/automation/src/printing.rs b/crates/automation/src/printing.rs index 74e732814..9a1c1a6b7 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -22,6 +22,16 @@ fn duplex(value: &str, cut_stack: bool) -> Result> { }) } +/// [`duplex`] for a job laid out as `layout`: the name is checked either way, and a booklet +/// decides two-sided itself (#2707), as the Print dialog does (`print::booklet_duplex`). +fn job_duplex(value: &str, layout: &Layout) -> Result> { + let chosen = duplex(value, matches!(layout, Layout::Multiple { order: PageOrder::CutStack, .. }))?; + Ok(match layout { + Layout::Booklet { subset, .. } => Some(print::booklet_duplex(*subset)), + _ => chosen, + }) +} + /// An invalid-arguments error saying `m`. fn bad(m: impl Into) -> ToolError { ToolError::InvalidArgs(m.into()) @@ -161,7 +171,7 @@ impl Automation { }, l => return Err(bad(format!("unknown layout {l:?}"))), }; - let duplex = duplex(a.opt_str("duplex")?.unwrap_or("off"), matches!(layout, Layout::Multiple { order: PageOrder::CutStack, .. }))?; + let duplex = job_duplex(a.opt_str("duplex")?.unwrap_or("off"), &layout)?; if a.opt_str("order")? == Some("cut-stack") && !matches!(layout, Layout::Multiple { .. }) { return Err(bad("cut-stack order needs layout multiple")); } @@ -215,6 +225,19 @@ impl Automation { mod tests { use super::*; + /// #2707: a booklet sets two-sided itself whatever `duplex` asks; a bad name is still an error. + #[test] + fn a_booklet_decides_two_sided_itself() { + let booklet = |subset| Layout::Booklet { subset, binding: Binding::Left }; + for asked in ["printer", "off", "long-edge", "short-edge"] { + assert_eq!(job_duplex(asked, &booklet(BookletSubset::BothSides)).ok(), Some(Some(spool::Duplex::ShortEdge)), "{asked}"); + assert_eq!(job_duplex(asked, &booklet(BookletSubset::FrontOnly)).ok(), Some(Some(spool::Duplex::Off)), "{asked}"); + assert_eq!(job_duplex(asked, &booklet(BookletSubset::BackOnly)).ok(), Some(Some(spool::Duplex::Off)), "{asked}"); + } + assert!(job_duplex("sideways", &booklet(BookletSubset::BothSides)).is_err()); + assert_eq!(job_duplex("long-edge", &Layout::Size(SizeMode::Fit)).ok(), Some(Some(spool::Duplex::LongEdge)), "other layouts keep it"); + } + /// `printer` leaves two-sided to the printer's own default (#2422); cut and stack still asks /// for single-sided sheets, and refuses an explicit edge. #[test] diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 1e009c117..a4c9b2019 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -543,7 +543,7 @@ pub fn tools() -> Vec { t( "doc_print", "Print", - "Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed; default all); subset odd/even (by page number, as Acrobat counts them: pages 2, 7-10 with even prints 2, 8 and 10, not every second page of the range); reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, order, border, auto_rotate; cut-stack order arranges single-sided sheets for cutting into piles and stacking left to right, top to bottom, keeping sheet order within each pile; duplex must be off), booklet (booklet_subset both/front/back, binding left/right), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid, ARCH A/B/C/D/E1/E, ANSI C/D/E, A0/A1/A2/A3/A4/A5/A6, or US #10 Envelope (short names or full preset labels); use layout actual for 100% in the print-ready PDF; printer drivers may still substitute paper or scale. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale, and options (driver options from printer_options, CUPS).", + "Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed; default all); subset odd/even (by page number, as Acrobat counts them: pages 2, 7-10 with even prints 2, 8 and 10, not every second page of the range); reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, order, border, auto_rotate; cut-stack order arranges single-sided sheets for cutting into piles and stacking left to right, top to bottom, keeping sheet order within each pile; duplex must be off), booklet (booklet_subset both/front/back, binding left/right; a booklet sets two-sided itself, as Acrobat does: both flips on the short edge, front or back prints one-sided), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid, ARCH A/B/C/D/E1/E, ANSI C/D/E, A0/A1/A2/A3/A4/A5/A6, or US #10 Envelope (short names or full preset labels); use layout actual for 100% in the print-ready PDF; printer drivers may still substitute paper or scale. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale, and options (driver options from printer_options, CUPS).", ) .with(schema( json!({ diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index 03780f4fc..959c76a14 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -149,6 +149,19 @@ pub enum BookletSubset { BackOnly, } +/// How a booklet goes through the printer (#2707): it decides two-sided printing itself, as +/// Acrobat does (its Print dialog hides Two-sided while Booklet is chosen; Using Acrobat X Pro, +/// Print dialog box options: "Acrobat automatically enables duplex printing, if available, for +/// booklet printing"). Both sides flip on the short edge, the fold of a 2-up sheet turned +/// sideways; front side only or back side only is one pass of a manual duplex and prints +/// single-sided. +pub fn booklet_duplex(subset: BookletSubset) -> spool::Duplex { + match subset { + BookletSubset::BothSides => spool::Duplex::ShortEdge, + BookletSubset::FrontOnly | BookletSubset::BackOnly => spool::Duplex::Off, + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum Binding { #[default] diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index fa3d440b4..93e8f79c9 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -1023,3 +1023,11 @@ fn odd_and_even_pages_are_page_numbers_not_places_in_the_range() { // A range with no page of the kind prints nothing. assert_eq!(select_pages(12, Some("3, 5"), &[], Subset::Even, false), Err(PrintError::NoPages)); } + +/// #2707: a booklet's two-sided setting follows its subset. +#[test] +fn a_booklet_prints_both_sides_on_the_short_edge() { + assert_eq!(booklet_duplex(BookletSubset::BothSides), spool::Duplex::ShortEdge); + assert_eq!(booklet_duplex(BookletSubset::FrontOnly), spool::Duplex::Off); + assert_eq!(booklet_duplex(BookletSubset::BackOnly), spool::Duplex::Off); +} diff --git a/crates/ui-egui/src/print_ui.rs b/crates/ui-egui/src/print_ui.rs index 12a941b15..fcc8d89d9 100644 --- a/crates/ui-egui/src/print_ui.rs +++ b/crates/ui-egui/src/print_ui.rs @@ -230,8 +230,15 @@ impl PrintDraft { printer: self.printer.clone(), copies: self.copies.max(1), collate: self.collate, - // Cut and stack needs single-sided sheets, whatever the printer's default. - duplex: if self.cut_and_stack() { Some(spool::Duplex::Off) } else { self.duplex }, + // Cut and stack needs single-sided sheets, whatever the printer's default; a booklet + // decides two-sided itself (#2707), leaving the Two-sided choice as it was. + duplex: if self.cut_and_stack() { + Some(spool::Duplex::Off) + } else if self.handling == Handling::Booklet { + Some(print::booklet_duplex(self.booklet_subset)) + } else { + self.duplex + }, scaling: spool::Scaling::Exact, grayscale: self.grayscale, title: title.to_string(), @@ -1019,22 +1026,26 @@ pub(crate) fn body( }); }); ui.end_row(); - ui.label(tl!("Two-sided:")); - ui.add_enabled_ui(!browser, |ui| { - combo( - ui, - "duplex", - &mut d.duplex, - &[ - (None, tl!("Printer default")), - (Some(spool::Duplex::Off), tl!("Off")), - (Some(spool::Duplex::LongEdge), tl!("Flip on long edge")), - (Some(spool::Duplex::ShortEdge), tl!("Flip on short edge")), - ], - 160.0, - ); - }); - ui.end_row(); + // A booklet decides two-sided itself (#2707), so the choice is hidden meanwhile, as + // in Acrobat's Print dialog; it is kept for the other layouts. + if d.handling != Handling::Booklet { + ui.label(tl!("Two-sided:")); + ui.add_enabled_ui(!browser, |ui| { + combo( + ui, + "duplex", + &mut d.duplex, + &[ + (None, tl!("Printer default")), + (Some(spool::Duplex::Off), tl!("Off")), + (Some(spool::Duplex::LongEdge), tl!("Flip on long edge")), + (Some(spool::Duplex::ShortEdge), tl!("Flip on short edge")), + ], + 160.0, + ); + }); + ui.end_row(); + } ui.label(tl!("Paper:")); let paper_name = PAPERS.get(d.paper).map_or("US Letter", |p| p.0); let before = d.paper; diff --git a/crates/ui-egui/tests/it/print.rs b/crates/ui-egui/tests/it/print.rs index ae325eaaa..21aeca128 100644 --- a/crates/ui-egui/tests/it/print.rs +++ b/crates/ui-egui/tests/it/print.rs @@ -873,3 +873,31 @@ fn restored_paper_keeps_preview_and_saved_sheet_dimensions() { assert!((size.0 - a4.0).abs() < 0.01 && (size.1 - a4.1).abs() < 0.01, "saved sheet: {size:?}"); std::fs::remove_file(out).unwrap(); } + +/// #2707: a booklet decides two-sided itself, as Acrobat does (its Print dialog hides Two-sided +/// while Booklet is chosen): the job flips on the short edge for both sides and prints +/// one-sided for the front or back only, and the Two-sided choice is kept for other layouts. +#[test] +fn a_booklet_decides_two_sided_itself() { + use pdfcraft_engine::print::{BookletSubset, spool::Duplex}; + let mut h = harness(); + assert!(h.state_mut().execute("print.dialog")); + h.run_steps(3); + h.state_mut().print_draft.duplex = Some(Duplex::LongEdge); + h.run_steps(1); + assert!(h.query_by_label("Two-sided:").is_some()); + h.get_by_label("Booklet").click(); + h.run_steps(2); + assert!(h.query_by_label("Two-sided:").is_none(), "hidden while Booklet is chosen"); + let d = &h.state().print_draft; + assert_eq!(d.duplex, Some(Duplex::LongEdge), "the Two-sided choice is kept"); + assert_eq!(d.job("x").duplex, Some(Duplex::ShortEdge), "both sides flip on the short edge"); + for subset in [BookletSubset::FrontOnly, BookletSubset::BackOnly] { + h.state_mut().print_draft.booklet_subset = subset; + assert_eq!(h.state().print_draft.job("x").duplex, Some(Duplex::Off), "{subset:?} is one pass of a manual duplex"); + } + h.get_by_label("Size").click(); + h.run_steps(2); + assert!(h.query_by_label("Two-sided:").is_some()); + assert_eq!(h.state().print_draft.job("x").duplex, Some(Duplex::LongEdge), "back to the choice"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 0a5850772..8a5538320 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -8073,8 +8073,8 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print"] -evidence = ["crates/print/src/tests.rs::booklets_pair_pages_for_folding"] -notes = "both/front/back, left/right binding" +evidence = ["crates/print/src/tests.rs::booklets_pair_pages_for_folding", "crates/ui-egui/tests/it/print.rs::a_booklet_decides_two_sided_itself", "crates/automation/src/printing.rs::a_booklet_decides_two_sided_itself", "crates/print/src/tests.rs::a_booklet_prints_both_sides_on_the_short_edge"] +notes = "both/front/back, left/right binding; a booklet decides two-sided itself, as Acrobat does (its Print dialog hides Two-sided while Booklet is chosen; Using Acrobat X Pro: Acrobat automatically enables duplex printing for booklet printing): both sides flip on the short edge, front or back only prints one-sided, and the Two-sided choice is kept for the other layouts (#2707)" [[feature]] id = "print.poster"