diff --git a/Cargo.lock b/Cargo.lock index 6c4d51a86c..a521b5c92f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1243,7 +1243,7 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=7ee35a9fbd47af572d55fd63dbf0333d1f00da17#7ee35a9fbd47af572d55fd63dbf0333d1f00da17" +source = "git+https://github.com/storytold/craft-libs?rev=b6b8a55b2c52bb83d0580216cbecb532af01970d#b6b8a55b2c52bb83d0580216cbecb532af01970d" dependencies = [ "craft-winprint-gdi", "log", diff --git a/Cargo.toml b/Cargo.toml index b0f3411624..bbf5fe876c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,7 @@ craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1 # the commit in `craft-i18n.rev`). Already in the tree through craft-ui. craft-i18n = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" } # Shared system spooler; document composition remains in pdfcraft-print. -craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "7ee35a9fbd47af572d55fd63dbf0333d1f00da17" } +craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "b6b8a55b2c52bb83d0580216cbecb532af01970d" } # Whether a path stored in a document may be opened (GoToR link targets). craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" } # craft-update: app-specific pretend versions apply to startup and manual checks. diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index 837a803e22..797e14e52f 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -475,6 +475,7 @@ fn app_creator<'a>( } app.set_startup_update_check(true); app.printer_source = Some(pdfcraft_ui_egui::system_printers()); + app.two_sided_source = Some(pdfcraft_ui_egui::system_two_sided()); app.os_key_store_ids = cfg!(any(target_os = "macos", target_os = "windows")); #[cfg(target_os = "macos")] { diff --git a/crates/print/src/spool.rs b/crates/print/src/spool.rs index b48c37cfb6..4b97d78658 100644 --- a/crates/print/src/spool.rs +++ b/crates/print/src/spool.rs @@ -24,8 +24,9 @@ thread_local! { /// Tests: the folder the print DLLs are looked for in, instead of the system's, so the /// public entry points can be checked against a Windows that lacks them. pub(crate) static SYSTEM32_FOR_TEST: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; - /// Tests: how many times [`printers`] asked the system's printer backend, so a test can tell - /// the guard answered without relying on the machine having no printers. + /// Tests: how many times [`printers`], [`printer_queues`] or [`two_sided`] asked the system's + /// printer backend, so a test can tell the guard answered without relying on the machine + /// having no printers. pub(crate) static PRINTER_BACKEND_CALLS: std::cell::Cell = const { std::cell::Cell::new(0) }; } @@ -59,6 +60,29 @@ pub fn printers() -> Vec { craft_print::printers() } +/// The system's print queues without asking any driver, so a Print dialog's list never waits on +/// a printer (#2192); none on a Windows installation without the print DLLs, as [`printers`]. +pub fn printer_queues() -> Vec { + if missing_print_dll().is_some() { + return Vec::new(); + } + #[cfg(all(windows, test))] + PRINTER_BACKEND_CALLS.with(|c| c.set(c.get().saturating_add(1))); + craft_print::printer_queues() +} + +/// Whether `printer`'s driver takes two-sided settings, read for that one printer; it can wait +/// on the device, so call it off the UI thread. Unknown on a Windows installation without the +/// print DLLs: reading it goes through them. +pub fn two_sided(printer: &str) -> Option { + if missing_print_dll().is_some() { + return None; + } + #[cfg(all(windows, test))] + PRINTER_BACKEND_CALLS.with(|c| c.set(c.get().saturating_add(1))); + craft_print::two_sided(printer) +} + /// The driver's job options for a printer (CUPS: its PPD, with the queue's current defaults); /// empty when it has none or on other platforms. Reads files and runs `lpoptions`, so call it /// when the user asks, not every frame. diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index 53a9961e3f..75a88442ab 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -1547,7 +1547,7 @@ fn missing_print_dlls_are_named() { #[cfg(windows)] #[test] fn printing_without_the_print_dlls_is_refused_before_the_spooler() { - use crate::spool::{PRINT_DLLS, PRINTER_BACKEND_CALLS, SYSTEM32_FOR_TEST, printers, submit, submit_with_progress}; + use crate::spool::{PRINT_DLLS, PRINTER_BACKEND_CALLS, SYSTEM32_FOR_TEST, printer_queues, printers, submit, submit_with_progress, two_sided}; let dir = std::env::temp_dir().join(format!("pdfcraft-print-no-dlls-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); @@ -1564,6 +1564,8 @@ fn printing_without_the_print_dlls_is_refused_before_the_spooler() { // Not only an empty list, which a machine without printers gives anyway: the backend wasn't asked. PRINTER_BACKEND_CALLS.with(|c| c.set(0)); assert!(printers().is_empty(), "no printers are listed"); + assert!(printer_queues().is_empty(), "no queues are listed"); + assert_eq!(two_sided("PdfCraft test: no such printer"), None, "two-sided support is unknown"); assert_eq!(PRINTER_BACKEND_CALLS.with(|c| c.get()), 0, "the printer backend wasn't asked"); SYSTEM32_FOR_TEST.with(|d| *d.borrow_mut() = None); let _ = std::fs::remove_dir_all(&dir); diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index f95aaf36d4..db5f2edd6d 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -133,6 +133,7 @@ impl PdfCraftApp { printer_source: None, options_source: None, spooler: None, + two_sided_source: None, print_tasks: print_ui::PrintTasks::default(), palette_open: false, palette_query: String::new(), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 95c4b7d32e..78435914ee 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -60,7 +60,8 @@ mod hotbar; mod panels; pub mod panes; pub use print_ui::{ - Handling as PrintHandling, OptionsSource, PrintDraft, PrinterSetup, PrinterSource, Spooler, Which as PrintWhich, system_printers, + Handling as PrintHandling, OptionsSource, PrintDraft, PrinterSetup, PrinterSource, Spooler, TwoSidedSource, Which as PrintWhich, system_printers, + system_two_sided, }; pub use redact_ui::{HiddenDraft, PagesDraft as RedactPagesDraft, RedactPrefs, SearchDraft as RedactSearchDraft, SearchMode as RedactSearchMode}; pub mod i18n; @@ -422,6 +423,9 @@ pub struct PdfCraftApp { pub options_source: Option, /// What print jobs go to: the system spooler unless a test stands in ([`Spooler`]). pub spooler: Option, + /// Reads the picked printer's two-sided support in the background (the desktop app sets it; + /// see [`TwoSidedSource`]). Without one the dialog goes by the printer list alone. + pub two_sided_source: Option, // A browser has no background print work: the type is empty there. #[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub(crate) print_tasks: print_ui::PrintTasks, diff --git a/crates/ui-egui/src/workflows/print_ui.rs b/crates/ui-egui/src/workflows/print_ui.rs index d7793a44d1..2f43f74ddd 100644 --- a/crates/ui-egui/src/workflows/print_ui.rs +++ b/crates/ui-egui/src/workflows/print_ui.rs @@ -28,10 +28,15 @@ pub(crate) const PREVIEW_BOX: (f32, f32) = (PREVIEW_W - 2.0 * PREVIEW_INSET, PRE /// Label printing reads the printed area of at most this many pages (each is a quick render on /// the UI thread); further pages print whole. pub(crate) const LABEL_CROP_PAGES: usize = 50; +/// Two-sided reads running at once. A driver Windows can't reach holds its read for about 48 s, +/// so picking printer after printer could otherwise start a thread for each; past this, the +/// printer picked is asked once a read finishes. +#[cfg(not(target_arch = "wasm32"))] +const TWO_SIDED_READS: usize = 4; /// Lists the printers off the UI thread (the desktop app sets [`PdfCraftApp::printer_source`]): -/// on Windows each driver's capabilities are read for its two-sided support, and CUPS' driverless discovery can wait on -/// the network. Without a source the dialog reads them as it opens, so tests stay deterministic. +/// CUPS' driverless discovery can wait on the network. Without a source the dialog reads them as +/// it opens, so tests stay deterministic. pub type PrinterSource = std::sync::Arc Vec + Send + Sync>; /// Where a printer's driver options come from: the spooler's (`spool::printer_options_in`, in the @@ -42,9 +47,21 @@ pub type OptionsSource = std::sync::Arc Vec Result + Send + Sync>; -/// The system spooler's printers, as the desktop app's [`PrinterSource`]. +/// The system spooler's printers, as the desktop app's [`PrinterSource`]: its queues only, with +/// no driver asked anything, so the list never waits on a printer (#2192). pub fn system_printers() -> PrinterSource { - std::sync::Arc::new(spool::printers) + std::sync::Arc::new(spool::printer_queues) +} + +/// Reads whether one printer's driver takes two-sided settings, off the UI thread, for the +/// printer picked (the desktop app sets [`PdfCraftApp::two_sided_source`]). The printer list +/// leaves it unknown: reading a driver Windows can't reach takes it about 48 s, so the list +/// doesn't wait for every printer's (#2192). +pub type TwoSidedSource = std::sync::Arc Option + Send + Sync>; + +/// The system spooler's answer, as the desktop app's [`TwoSidedSource`]. +pub fn system_two_sided() -> TwoSidedSource { + std::sync::Arc::new(spool::two_sided) } /// The Print dialog's work in the background: the printer list being read, and the jobs being @@ -53,6 +70,9 @@ pub fn system_printers() -> PrinterSource { pub(crate) struct PrintTasks { #[cfg(not(target_arch = "wasm32"))] printers: Option>>, + /// Two-sided support being read, by printer. + #[cfg(not(target_arch = "wasm32"))] + two_sided: Vec<(String, std::sync::mpsc::Receiver>)>, #[cfg(not(target_arch = "wasm32"))] jobs: Vec, /// The progress card is showing a print job (it is shared with other long jobs). @@ -82,7 +102,7 @@ fn listed_printers() -> Vec { } #[cfg(not(target_arch = "wasm32"))] { - spool::printers() + spool::printer_queues() } } @@ -363,6 +383,7 @@ impl PdfCraftApp { d.printers_loading = false; } } + self.poll_two_sided(); let mut finished = Vec::new(); self.print_tasks.jobs.retain_mut(|job| { loop { @@ -421,6 +442,48 @@ impl PdfCraftApp { } } + /// Pick up two-sided answers that arrived, and ask for the picked printer's when the list + /// doesn't say, nobody has asked yet and fewer than [`TWO_SIDED_READS`] reads are running. + #[cfg(not(target_arch = "wasm32"))] + fn poll_two_sided(&mut self) { + let d = &mut self.print_draft; + self.print_tasks.two_sided.retain(|(printer, rx)| match rx.try_recv() { + Ok(two_sided) => { + d.two_sided.insert(printer.clone(), two_sided); + false + } + Err(std::sync::mpsc::TryRecvError::Empty) => true, + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + d.two_sided.insert(printer.clone(), None); + false + } + }); + if self.dialog != Some(crate::Dialog::Print) { + return; + } + let Some(source) = self.two_sided_source.clone() else { return }; + let Some(printer) = d.printers.iter().find(|p| d.printer.as_ref() == Some(&p.name)) else { return }; + if printer.two_sided.is_some() + || d.two_sided.contains_key(&printer.name) + || self.print_tasks.two_sided.iter().any(|(n, _)| *n == printer.name) + || self.print_tasks.two_sided.len() >= TWO_SIDED_READS + { + return; + } + let name = printer.name.clone(); + let (tx, rx) = std::sync::mpsc::channel(); + let ctx = self.ctx.clone(); + let asked = name.clone(); + std::thread::spawn(move || { + // The receiver may be gone (the app quit): nothing to report to then. + let _ = tx.send(source(&asked)); + if let Some(ctx) = ctx { + ctx.request_repaint(); + } + }); + self.print_tasks.two_sided.push((name, rx)); + } + fn report_print_job(&mut self, printer: &str, result: Result) { match result { Ok(msg) if msg.is_empty() => self.notify(crate::i18n::fmt(tl!("Sent to {printer}"), &[("printer", printer)])), diff --git a/crates/ui-egui/src/workflows/print_ui/body.rs b/crates/ui-egui/src/workflows/print_ui/body.rs index 1da2f6caa5..e4cc51916f 100644 --- a/crates/ui-egui/src/workflows/print_ui/body.rs +++ b/crates/ui-egui/src/workflows/print_ui/body.rs @@ -238,7 +238,7 @@ 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 == Some(false) + && p.two_sided.or_else(|| d.two_sided.get(&p.name).copied().flatten()) == 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)); } diff --git a/crates/ui-egui/src/workflows/print_ui/draft.rs b/crates/ui-egui/src/workflows/print_ui/draft.rs index 53a8d2c430..d70aba8ee8 100644 --- a/crates/ui-egui/src/workflows/print_ui/draft.rs +++ b/crates/ui-egui/src/workflows/print_ui/draft.rs @@ -94,6 +94,9 @@ pub struct PrintDraft { pub driver_options_loading: Option, /// Properties… asked for this printer's options; the app starts the read on the next frame. pub read_options_for: Option, + /// Whether each printer's driver takes two-sided settings, as read in the background for + /// the printer picked (see [`TwoSidedSource`](super::TwoSidedSource)); `None` when it couldn't be read. + pub two_sided: std::collections::BTreeMap>, /// The paper, sizing and two-sided choice each printer was last printed with, the printer /// printed to last first; kept across restarts (#2001). pub setups: Vec<(String, PrinterSetup)>, @@ -148,6 +151,7 @@ impl Default for PrintDraft { crop_pages: None, driver_options_loading: None, read_options_for: None, + two_sided: Default::default(), setups: Vec::new(), } } diff --git a/crates/ui-egui/tests/it/print.rs b/crates/ui-egui/tests/it/print.rs index 322e6a0752..e85a5b7099 100644 --- a/crates/ui-egui/tests/it/print.rs +++ b/crates/ui-egui/tests/it/print.rs @@ -519,6 +519,46 @@ fn custom_page_cannot_silently_match_a_standard_paper() { assert_eq!(h.state().print_draft.size, before.size); } +/// Windows' printer list doesn't say which drivers take two-sided settings: reading a printer +/// Windows can't reach takes it about 48 s (#2192). The dialog lists every printer at once and +/// asks only about the one picked, in the background, so a printer nobody picks is never waited on. +#[test] +fn two_sided_support_is_read_only_for_the_printer_picked() { + use pdfcraft_engine::print::spool::Printer; + use std::sync::{Arc, Mutex}; + let mut h = harness(); + h.state_mut().printer_source = Some(Arc::new(|| { + vec![ + Printer { name: "Office PCL".into(), default: true, two_sided: None, description: String::new() }, + Printer { name: "Office PS".into(), default: false, two_sided: None, description: String::new() }, + Printer { name: "Away WSD".into(), default: false, two_sided: None, description: String::new() }, + ] + })); + let asked = Arc::new(Mutex::new(Vec::::new())); + let log = asked.clone(); + h.state_mut().two_sided_source = Some(Arc::new(move |name: &str| { + log.lock().unwrap().push(name.to_string()); + Some(name != "Office PS") + })); + assert!(h.state_mut().execute("print.dialog")); + let settle = |h: &mut Harness<'static, PdfCraftApp>, done: &dyn Fn(&PdfCraftApp) -> bool| { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while !done(h.state()) && std::time::Instant::now() < deadline { + h.run_steps(1); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + h.run_steps(2); + }; + settle(&mut h, &|a| a.print_draft.two_sided.contains_key("Office PCL")); + assert_eq!(h.state().print_draft.printers.len(), 3); + let note = "doesn't take two-sided settings"; + assert!(h.query_by_label_contains(note).is_none(), "a driver that prints two-sided as asked"); + h.state_mut().print_draft.printer = Some("Office PS".into()); + settle(&mut h, &|a| a.print_draft.two_sided.contains_key("Office PS")); + h.get_by_label_contains(note); + assert_eq!(*asked.lock().unwrap(), ["Office PCL", "Office PS"], "each picked printer once, never the others"); +} + /// A driver Windows reports without two-sided printing keeps its own setting whatever Two-sided /// says (some print both sides with it Off), so the dialog says where to change it. #[test] @@ -545,6 +585,92 @@ fn a_printer_without_two_sided_printing_points_to_its_properties() { h.get_by_label_contains(note); } +/// A list that already says which drivers take two-sided settings is used as it is: no driver is +/// asked again, whichever printer is picked. +#[test] +fn a_listed_two_sided_answer_is_not_asked_again() { + use pdfcraft_engine::print::spool::Printer; + use std::sync::{Arc, Mutex}; + let mut h = harness(); + h.state_mut().printer_source = Some(Arc::new(|| { + vec![ + Printer { name: "Office PCL".into(), default: true, two_sided: Some(true), description: String::new() }, + Printer { name: "Office PS".into(), default: false, two_sided: Some(false), description: String::new() }, + ] + })); + let asked = Arc::new(Mutex::new(Vec::::new())); + let log = asked.clone(); + h.state_mut().two_sided_source = Some(Arc::new(move |name: &str| { + log.lock().unwrap().push(name.to_string()); + None + })); + assert!(h.state_mut().execute("print.dialog")); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while h.state().print_draft.printers.is_empty() && std::time::Instant::now() < deadline { + h.run_steps(1); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + h.run_steps(2); + h.state_mut().print_draft.printer = Some("Office PS".into()); + h.run_steps(4); + h.get_by_label_contains("doesn't take two-sided settings"); + assert!(asked.lock().unwrap().is_empty(), "the list's answers are used: {:?}", asked.lock().unwrap()); +} + +/// Reads that hang (printers Windows can't reach) don't pile up as the user picks printer after +/// printer: at most four run at once, and the printer picked is asked once one finishes. +#[test] +fn hanging_two_sided_reads_are_capped() { + use pdfcraft_engine::print::spool::Printer; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::{Arc, Mutex}; + let names: Vec = (1..=6).map(|i| format!("Away WSD {i}")).collect(); + let listed = names.clone(); + let mut h = harness(); + h.state_mut().printer_source = Some(Arc::new(move || { + listed.iter().enumerate().map(|(i, n)| Printer { name: n.clone(), default: i == 0, two_sided: None, description: String::new() }).collect() + })); + let asked = Arc::new(Mutex::new(Vec::::new())); + let released = Arc::new(AtomicBool::new(false)); + let (log, gate) = (asked.clone(), released.clone()); + h.state_mut().two_sided_source = Some(Arc::new(move |name: &str| { + log.lock().unwrap().push(name.to_string()); + // Hold the read like an unreachable printer, until the test lets it go (10 s at most). + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while !gate.load(Ordering::SeqCst) && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + Some(true) + })); + assert!(h.state_mut().execute("print.dialog")); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while h.state().print_draft.printers.is_empty() && std::time::Instant::now() < deadline { + h.run_steps(1); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + for name in &names { + h.state_mut().print_draft.printer = Some(name.clone()); + h.run_steps(2); + } + let started = |a: &Arc>>| a.lock().unwrap().len(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while started(&asked) < 4 && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + h.run_steps(2); + let mut first = asked.lock().unwrap().clone(); + first.sort(); + assert_eq!(first, names[..4], "four reads at once, the rest wait"); + released.store(true, Ordering::SeqCst); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while !h.state().print_draft.two_sided.contains_key(&names[5]) && std::time::Instant::now() < deadline { + h.run_steps(1); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert_eq!(h.state().print_draft.two_sided.get(&names[5]), Some(&Some(true)), "the printer picked is asked once a read finishes"); + assert!(!h.state().print_draft.two_sided.contains_key(&names[4]), "a printer no longer picked isn't asked"); +} + /// Print as image (Acrobat's Advanced Print Setup) is a choice in the Print dialog's Advanced /// section: the saved print-ready PDF carries the page as a picture, no text. #[test]