Print: list printers without asking drivers; ask only about the one picked (#2192) (#3554)

* Print: list printers without asking drivers; ask only about the one picked (#2192)

On Windows, craft-print's printers() reads every driver's capabilities to
report two-sided support. Windows takes about 48 s to give up on a printer
it can't reach, such as a WSD printer on another network, so the Print
dialog waited for each one before it listed anything (#2192).

The dialog now lists printers from craft-print's printer_queues(), which
asks no driver, and asks two_sided() about the printer the user picks, on
a background thread (PdfCraftApp::two_sided_source, set by the desktop app
like printer_source). Each answer is kept for the session, so a printer is
asked about at most once, and one nobody picks is never waited on. The
"doesn't take two-sided settings" note shows once the answer arrives.

The automation printers tool still uses printers(), so it keeps reporting
two_sided.

Needs the craft-libs change that adds craft_print::two_sided.

* Build: take craft-print from storytold/craft-libs#133

Pins craft-print to the head of storytold/craft-libs#133 (b6a3b87), which
adds craft_print::two_sided as one commit on craft-libs main at 5ee809f.
craft-print is otherwise unchanged from 7ee35a9, the rev main pins now; the
other craft-libs crates stay where they are. Fetched through the
storytold/craft-libs URL, so cargo-deny's allow-git is unchanged. To be
replaced by the merge commit once #133 lands.

* Print: run at most four two-sided reads at once

A driver Windows can't reach holds its two-sided read for about 48 s, so
picking printer after printer started a thread for each. At most four now
run at once; past that, the printer picked is asked once a read finishes.

Tests: hanging reads are capped and the picked printer is asked after one
finishes; a list that already says whether a driver takes two-sided
settings is used as it is, with no driver asked.

* Repin craft-print to craft-libs main, where #133 merged (b6b8a55b)

---------

Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
This commit is contained in:
art804andFredApps authored and GitHub committed 2026-11-10 02:17:00 +01:00
1 parent ca66667f33
commit dff02827fd
11 files changed
+237 -12

No files matched your search

Generated
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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.
+1
View File
@@ -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")]
{
+26 -2
View File
@@ -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<Option<std::path::PathBuf>> = 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<usize> = const { std::cell::Cell::new(0) };
}
@@ -59,6 +60,29 @@ pub fn printers() -> Vec<Printer> {
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<Printer> {
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<bool> {
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.
+3 -1
View File
@@ -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);
+1
View File
@@ -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(),
+5 -1
View File
@@ -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<OptionsSource>,
/// What print jobs go to: the system spooler unless a test stands in ([`Spooler`]).
pub spooler: Option<Spooler>,
/// 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<TwoSidedSource>,
// 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,
+68 -5
View File
@@ -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<dyn Fn() -> Vec<spool::Printer> + 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<dyn Fn(&str) -> Vec<spool::PrinterOption
/// print-ready PDF and the job and answers as the spooler would. Called off the GUI thread.
pub type Spooler = std::sync::Arc<dyn Fn(&[u8], &spool::Job) -> Result<String, String> + 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<dyn Fn(&str) -> Option<bool> + 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<std::sync::mpsc::Receiver<Vec<spool::Printer>>>,
/// Two-sided support being read, by printer.
#[cfg(not(target_arch = "wasm32"))]
two_sided: Vec<(String, std::sync::mpsc::Receiver<Option<bool>>)>,
#[cfg(not(target_arch = "wasm32"))]
jobs: Vec<PrintJob>,
/// The progress card is showing a print job (it is shared with other long jobs).
@@ -82,7 +102,7 @@ fn listed_printers() -> Vec<spool::Printer> {
}
#[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<String, String>) {
match result {
Ok(msg) if msg.is_empty() => self.notify(crate::i18n::fmt(tl!("Sent to {printer}"), &[("printer", printer)])),
@@ -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));
}
@@ -94,6 +94,9 @@ pub struct PrintDraft {
pub driver_options_loading: Option<String>,
/// Properties… asked for this printer's options; the app starts the read on the next frame.
pub read_options_for: Option<String>,
/// 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<String, Option<bool>>,
/// 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(),
}
}
+126
View File
@@ -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::<String>::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::<String>::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<String> = (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::<String>::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<Mutex<Vec<String>>>| 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]