diff --git a/crates/automation/src/printing.rs b/crates/automation/src/printing.rs index ba15639039..ca423b194b 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -23,15 +23,24 @@ 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> { +/// decides two-sided itself (#2707) as far as the printer's driver takes it, as the Print dialog +/// does (`print::booklet_job_duplex`). `two_sided` says whether it does; it is only asked for a +/// booklet's both sides, the one job it changes. +fn job_duplex(value: &str, layout: &Layout, two_sided: impl FnOnce() -> Option) -> Result> { let chosen = duplex(value, matches!(layout, Layout::Multiple { order: PageOrder::CutStack, .. }))?; Ok(match layout { - Layout::Booklet { subset, .. } => Some(print::booklet_duplex(*subset)), + Layout::Booklet { subset: BookletSubset::BothSides, .. } => print::booklet_job_duplex(BookletSubset::BothSides, two_sided()), + Layout::Booklet { subset, .. } => print::booklet_job_duplex(*subset, None), _ => chosen, }) } +/// `printer` (a name, or "default" for the default printer) in `printers`; `None` when the list +/// has no such printer. +fn printer_in(printers: Vec, printer: &str) -> Option { + printers.into_iter().find(|p| if printer == "default" { p.default } else { p.name == printer }) +} + /// An invalid-arguments error saying `m`. fn bad(m: impl Into) -> ToolError { ToolError::InvalidArgs(m.into()) @@ -242,7 +251,15 @@ impl Automation { }, l => Layout::Size(print::names::parse_size_mode(l, 100.0).ok_or_else(|| bad(format!("unknown layout {l:?}")))?), }; - let duplex = job_duplex(a.opt_str("duplex")?.unwrap_or("off"), &layout)?; + let printer = a.opt_str("printer")?; + // The printer whose driver a booklet's both sides were decided for. The job goes to it by + // name, so "default" can't become another printer while the document is rendered. The + // queues are listed without asking every driver (#2192); only this one is asked. + let mut asked = None; + let duplex = job_duplex(a.opt_str("duplex")?.unwrap_or("off"), &layout, || { + asked = printer.and_then(|p| printer_in(spool::printer_queues(), p)); + spool::two_sided(&asked.as_ref()?.name) + })?; if (a.opt_int("cols")?.is_some() || a.opt_int("rows")?.is_some()) && !matches!(layout, Layout::Multiple { .. }) { return Err(bad("cols and rows need layout multiple")); } @@ -285,13 +302,14 @@ impl Automation { 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")?) { + match (a.opt_str("path")?, printer) { (Some(p), None) => { let path = self.resolve(p, true)?; write_atomic(&path, &bytes)?; out["path"] = json!(path.to_string_lossy()); } (None, Some(printer)) => { + let printer = asked.as_ref().map_or(printer, |p| p.name.as_str()); let job = spool_job(a, printer, self.doc(a)?.name.clone(), duplex, paper_index, settings.raster, &laid_out)?; let bytes = pdfcraft_engine::spooler::spooler_copy(bytes); out["job"] = json!(spool::submit(&bytes, &job).map_err(|e| failed(e.to_string()))?); @@ -320,16 +338,41 @@ mod tests { use super::*; /// #2707: a booklet sets two-sided itself whatever `duplex` asks; a bad name is still an error. + /// #5772: its both sides leave two-sided to a driver that doesn't take the setting, and only + /// they look the printer up. #[test] fn a_booklet_decides_two_sided_itself() { let booklet = |subset| Layout::Booklet { subset, binding: Binding::Left, creep: 0.0 }; + let unread = || -> Option { panic!("the printer's two-sided support is read for nothing") }; 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_eq!(job_duplex(asked, &booklet(BookletSubset::BothSides), || Some(true)).ok(), Some(Some(spool::Duplex::ShortEdge)), "{asked}"); + assert_eq!(job_duplex(asked, &booklet(BookletSubset::BothSides), || None).ok(), Some(Some(spool::Duplex::ShortEdge)), "{asked}"); + assert_eq!(job_duplex(asked, &booklet(BookletSubset::BothSides), || Some(false)).ok(), Some(None), "{asked}"); + assert_eq!(job_duplex(asked, &booklet(BookletSubset::FrontOnly), unread).ok(), Some(Some(spool::Duplex::Off)), "{asked}"); + assert_eq!(job_duplex(asked, &booklet(BookletSubset::BackOnly), unread).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"); + assert!(job_duplex("sideways", &booklet(BookletSubset::BothSides), || None).is_err()); + assert_eq!(job_duplex("long-edge", &Layout::Size(SizeMode::Fit), unread).ok(), Some(Some(spool::Duplex::LongEdge)), "other layouts keep it"); + } + + /// #5772: a booklet's printer is found by name, or as the default for "default", and only + /// what the list says about it counts. "default" is found by its name, which the job is sent + /// to. + #[test] + fn a_booklets_printer_comes_from_the_list() { + let printer = |name: &str, default, two_sided| spool::Printer { name: name.into(), default, two_sided, description: String::new() }; + let list = [printer("Epson", false, Some(false)), printer("Laser", true, Some(true)), printer("Network", false, None)]; + let found = |list: &[spool::Printer], name| printer_in(list.to_vec(), name).map(|p| (p.name, p.two_sided)); + assert_eq!(found(&list, "Epson"), Some(("Epson".into(), Some(false)))); + assert_eq!(found(&list, "Laser"), Some(("Laser".into(), Some(true)))); + assert_eq!(found(&list, "default"), Some(("Laser".into(), Some(true))), "the default printer"); + assert_eq!(found(&list, "Network"), Some(("Network".into(), None)), "the list doesn't say"); + assert_eq!(found(&list, "Gone"), None, "no such printer"); + assert_eq!(found(&list, "epson"), None, "names are exact"); + let default_one_sided = [printer("Epson", true, Some(false)), printer("Laser", false, Some(true))]; + assert_eq!(found(&default_one_sided, "default"), Some(("Epson".into(), Some(false))), "the default printer"); + let without_default = [printer("Epson", false, Some(false))]; + assert_eq!(found(&without_default, "default"), None); } /// `printer` leaves two-sided to the printer's own default (#2422); cut and stack still asks diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index 84cca070da..885a5953b3 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -39,7 +39,7 @@ use imposition::{PAGES_LISTED, appearance, appearance_matrix, decoded_contents, pub use imposition::{impose, keep_printed, sheet_pages}; pub use settings::{ Binding, BookletSubset, Content, Crop, LABEL_PAPER, Layout, MAX_GRID, Orientation, PAPERS, PageOrder, Raster, Settings, SizeMode, booklet_duplex, - cups_media, driver_needs_grayscale, media_option, + booklet_job_duplex, cups_media, driver_needs_grayscale, media_option, }; #[cfg(test)] use sheets::text_direction; diff --git a/crates/print/src/settings.rs b/crates/print/src/settings.rs index bdcb3e3540..619f775926 100644 --- a/crates/print/src/settings.rs +++ b/crates/print/src/settings.rs @@ -50,6 +50,20 @@ pub fn booklet_duplex(subset: BookletSubset) -> spool::Duplex { } } +/// What a booklet's job asks the printer for, given whether its driver takes two-sided settings +/// from other programs (`two_sided`, as the printer list reports it): [`booklet_duplex`], only as +/// Acrobat enables two-sided printing "if available". A driver that doesn't (`Some(false)`) would +/// refuse a two-sided job and print nothing, so both sides ask for nothing and its own setting +/// applies, such as manual two-sided printing chosen in its Properties (#5772). A driver of +/// unknown support is asked for two-sided as before; one side on its own is always one-sided. +pub fn booklet_job_duplex(subset: BookletSubset, two_sided: Option) -> Option { + match booklet_duplex(subset) { + spool::Duplex::Off => Some(spool::Duplex::Off), + _ if two_sided == Some(false) => None, + two_sided_duplex => Some(two_sided_duplex), + } +} + #[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 402b2aaa13..29d22d8eff 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -1538,6 +1538,19 @@ fn a_booklet_prints_both_sides_on_the_short_edge() { assert_eq!(booklet_duplex(BookletSubset::BackOnly), spool::Duplex::Off); } +/// #5772: both sides ask a driver that takes two-sided settings for the short edge, and one that +/// doesn't for nothing, so its own setting (manual two-sided, say) prints the booklet rather than +/// the job being refused. One side on its own asks every driver for one side. +#[test] +fn a_booklet_leaves_two_sided_to_a_driver_that_keeps_its_own() { + use BookletSubset::{BackOnly, BothSides, FrontOnly}; + for (two_sided, both) in [(Some(true), Some(spool::Duplex::ShortEdge)), (None, Some(spool::Duplex::ShortEdge)), (Some(false), None)] { + assert_eq!(booklet_job_duplex(BothSides, two_sided), both, "{two_sided:?}"); + assert_eq!(booklet_job_duplex(FrontOnly, two_sided), Some(spool::Duplex::Off), "{two_sided:?}"); + assert_eq!(booklet_job_duplex(BackOnly, two_sided), Some(spool::Duplex::Off), "{two_sided:?}"); + } +} + /// #3502: `/Contents` array pieces are joined with a space, matching the renderer, so a `%` /// comment at the end of a piece without a line break continues through the first line of the /// next piece in both `impose` and `text_reads_up`. diff --git a/crates/ui-egui/src/workflows/print_ui.rs b/crates/ui-egui/src/workflows/print_ui.rs index 89b51b34e2..c5b90ec12a 100644 --- a/crates/ui-egui/src/workflows/print_ui.rs +++ b/crates/ui-egui/src/workflows/print_ui.rs @@ -832,6 +832,33 @@ mod tests { assert!(cut.settings(4, &[]).is_err()); } + /// #5772: a booklet's both sides ask the printer for the short edge, unless its driver doesn't + /// take two-sided settings: it would refuse the job, so its own setting (manual two-sided, say) + /// prints it. One side on its own stays one-sided. + #[test] + fn a_booklet_leaves_two_sided_to_a_driver_that_keeps_its_own() { + // Listed as the dialog lists them (#3554), drivers unasked; the answers for the printers + // picked arrive in `two_sided`, and the network printer's never came. + let printer = |name: &str| spool::Printer { name: name.into(), default: false, two_sided: None, description: String::new() }; + let mut d = PrintDraft { + printers: vec![printer("Epson"), printer("Laser"), printer("Network")], + two_sided: [("Epson".to_string(), Some(false)), ("Laser".to_string(), Some(true))].into_iter().collect(), + handling: Handling::Booklet, + ..PrintDraft::default() + }; + for (name, both) in [("Epson", None), ("Laser", Some(spool::Duplex::ShortEdge)), ("Network", Some(spool::Duplex::ShortEdge))] { + d.printer = Some(name.into()); + d.booklet_subset = BookletSubset::BothSides; + assert_eq!(d.job("a.pdf").duplex, both, "{name}"); + d.booklet_subset = BookletSubset::FrontOnly; + assert_eq!(d.job("a.pdf").duplex, Some(spool::Duplex::Off), "{name}"); + } + // Save as PDF has no driver: as before. + d.printer = None; + d.booklet_subset = BookletSubset::BothSides; + assert_eq!(d.job("a.pdf").duplex, Some(spool::Duplex::ShortEdge)); + } + /// A job being spooled holds the progress card, counting its sheets as the spooler reports /// them; the card goes when the job ends and its answer arrives as a notice. A card another /// long job is showing is left alone. diff --git a/crates/ui-egui/src/workflows/print_ui/body.rs b/crates/ui-egui/src/workflows/print_ui/body.rs index 43e5323bd3..d4c9b3444d 100644 --- a/crates/ui-egui/src/workflows/print_ui/body.rs +++ b/crates/ui-egui/src/workflows/print_ui/body.rs @@ -288,10 +288,8 @@ pub(crate) fn body( } }); // Windows says which drivers can't print two-sided as they're asked; they keep their - // own setting, even when Two-sided is Off. - if let Some(p) = d.printers.iter().find(|p| d.printer.as_ref() == Some(&p.name)) - && p.two_sided.or_else(|| d.two_sided.get(&p.name).copied().flatten()) == Some(false) - { + // own setting, even when Two-sided is Off, and for a booklet's both sides. + if d.printer_two_sided() == Some(false) { ui.label(egui::RichText::new(tl!("This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties…")).small().color(t.text_muted)); } if d.printer.as_deref() == Some(crate::web_print::BROWSER_PRINTER) { diff --git a/crates/ui-egui/src/workflows/print_ui/draft.rs b/crates/ui-egui/src/workflows/print_ui/draft.rs index e7ad00df79..cf65351141 100644 --- a/crates/ui-egui/src/workflows/print_ui/draft.rs +++ b/crates/ui-egui/src/workflows/print_ui/draft.rs @@ -312,6 +312,16 @@ impl PrintDraft { self.handling == Handling::Multiple && self.order == PageOrder::CutStack } + /// Whether the chosen printer's driver takes two-sided settings from other programs: + /// `Some(false)` for one that keeps its own (no two-sided printing, or only manual two-sided in + /// its Properties); `None` when unknown, and for Save as PDF. The printer list says when it + /// knows; on Windows it lists the queues without asking their drivers (#3554), and the answer + /// for the printer picked arrives in [`PrintDraft::two_sided`]. + pub fn printer_two_sided(&self) -> Option { + let p = self.printers.iter().find(|p| self.printer.as_ref() == Some(&p.name))?; + p.two_sided.or_else(|| self.two_sided.get(&p.name).copied().flatten()) + } + /// The spooler job for this print: the dialog's copies, two-sided and colour settings, the /// driver options changed from the printer's defaults, and the chosen paper. pub fn job(&self, title: &str) -> spool::Job { @@ -320,11 +330,12 @@ impl PrintDraft { copies: self.copies.max(1), collate: self.collate, // 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. + // decides two-sided itself (#2707), as far as the driver takes it (#5772), 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)) + print::booklet_job_duplex(self.booklet_subset, self.printer_two_sided()) } else { self.duplex }, diff --git a/crates/ui-egui/tests/it/print.rs b/crates/ui-egui/tests/it/print.rs index 5e57179b13..89f9885477 100644 --- a/crates/ui-egui/tests/it/print.rs +++ b/crates/ui-egui/tests/it/print.rs @@ -1199,12 +1199,24 @@ fn a_custom_paper_prints_at_its_size_and_its_printer_keeps_it() { /// #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. +/// #5772: on a printer whose driver doesn't take two-sided settings, both sides leave it to the +/// driver's own setting, and the dialog says so. #[test] fn a_booklet_decides_two_sided_itself() { - use pdfcraft_engine::print::{BookletSubset, spool::Duplex}; + use pdfcraft_engine::print::{BookletSubset, spool::Duplex, spool::Printer}; let mut h = harness(); + // Printers of its own, listed as the dialog lists them (drivers unasked, #3554), with what each + // driver said about two-sided: the machine's would decide what a booklet asks for, and on a + // machine with none the list that loads would take these away. + h.state_mut().printer_source = Some(std::sync::Arc::new(|| { + let printer = |name: &str| Printer { name: name.into(), default: name == "Laser", two_sided: None, description: String::new() }; + vec![printer("Laser"), printer("Inkjet")] + })); + h.state_mut().two_sided_source = None; + h.state_mut().print_draft.two_sided = [("Laser".to_string(), Some(true)), ("Inkjet".to_string(), Some(false))].into_iter().collect(); assert!(h.state_mut().execute("print.dialog")); - h.run_steps(3); + settle(&mut h); + assert_eq!(h.state().print_draft.printer.as_deref(), Some("Laser")); h.state_mut().print_draft.duplex = Some(Duplex::LongEdge); h.run_steps(1); assert!(h.query_by_role_and_label(Role::ComboBox, "Two-sided:").is_some()); @@ -1222,6 +1234,18 @@ fn a_booklet_decides_two_sided_itself() { 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"); } + let keeps_its_own = + "This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties…"; + assert!(h.query_by_label(keeps_its_own).is_none()); + h.state_mut().print_draft.printer = Some("Inkjet".into()); + h.state_mut().print_draft.booklet_subset = BookletSubset::BothSides; + h.run_steps(2); + assert_eq!(h.state().print_draft.job("x").duplex, None, "the inkjet's own two-sided setting prints both sides"); + assert!(h.query_by_label(keeps_its_own).is_some(), "the dialog says so"); + h.state_mut().print_draft.booklet_subset = BookletSubset::FrontOnly; + assert_eq!(h.state().print_draft.job("x").duplex, Some(Duplex::Off), "one side stays one-sided"); + h.state_mut().print_draft.printer = Some("Laser".into()); + h.run_steps(1); h.get_by_label("Size").click(); h.run_steps(2); assert!(h.query_by_role_and_label(Role::ComboBox, "Two-sided:").is_some()); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index ce9188932e..eced73a64a 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -8309,8 +8309,8 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print"] -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", "crates/print/src/tests.rs::booklet_creep_moves_inner_sheets_toward_the_fold"] -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); creep compensation (points per sheet, default 0) moves each sheet's pages toward the fold by their sheet's place from the outside, clipped at the fold (#6777); the amount is entered, not worked out from the paper's thickness" +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", "crates/print/src/tests.rs::booklet_creep_moves_inner_sheets_toward_the_fold", "crates/print/src/tests.rs::a_booklet_leaves_two_sided_to_a_driver_that_keeps_its_own", "crates/ui-egui/src/workflows/print_ui.rs::a_booklet_leaves_two_sided_to_a_driver_that_keeps_its_own"] +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 (as Acrobat, \"if available\": a printer whose driver doesn't take two-sided settings, such as one with only manual two-sided in its Properties or Microsoft Print to PDF, is asked for nothing, so its own setting applies rather than the job being refused, #5772), front or back only prints one-sided, and the Two-sided choice is kept for the other layouts (#2707). Not yet: a guided manual two-sided pass (front, turn the stack, back) on such a printer; creep compensation (points per sheet, default 0) moves each sheet's pages toward the fold by their sheet's place from the outside, clipped at the fold (#6777); the amount is entered, not worked out from the paper's thickness" [[feature]] id = "print.poster"