Skip to main content

winprint_kit/
pipeline.rs

1//! Print orchestration: download ? type detection ? dispatch to PDF / image / XPS / HTML / Office printers.
2//!
3//! Progress is reported via the [`StatusSink`] callback; the event sink is implemented by the caller (e.g. tauri `emit`).
4
5use crate::download;
6use crate::file_type;
7use crate::print::{build_print_ticket, extract_capabilities, PrintOptions, PrinterCapabilities};
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11use winprint_ext::printer::{FilePrinter, ImagePrinter, PdfiumPrinter, PrinterDevice, XpsPrinter};
12use winprint_ext::ticket::PrintCapabilities;
13
14#[cfg(feature = "html")]
15use winprint_ext::ticket::FeatureOptionPack;
16
17#[cfg(feature = "html")]
18use crate::html;
19#[cfg(feature = "html")]
20use tempfile::Builder as TempFileBuilder;
21#[cfg(feature = "office")]
22use crate::office;
23#[cfg(feature = "office")]
24use crate::office::OfficeKind;
25#[cfg(feature = "office")]
26use std::fs::remove_file;
27#[cfg(feature = "office")]
28use tokio::sync::Semaphore;
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum PrintStatus {
32    Printing,
33    Success,
34    Failed,
35}
36
37/// Machine-readable cause of a failed print. Applications localize `kind`; `detail` is a
38/// concise English fallback for logs and for kinds the app does not localize.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct PrintFailure {
41    pub kind: PrintFailureKind,
42    pub detail: String,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum PrintFailureKind {
47    #[cfg(feature = "html")]
48    WebView2NotInstalled,
49    #[cfg(feature = "office")]
50    OfficeNotInstalled(OfficeKind),
51    Other,
52}
53
54impl PrintFailure {
55    /// Generic failure; `detail` is the cause.
56    pub fn other(detail: impl Into<String>) -> Self {
57        Self {
58            kind: PrintFailureKind::Other,
59            detail: detail.into(),
60        }
61    }
62
63    #[cfg(feature = "html")]
64    pub fn webview2_missing() -> Self {
65        Self {
66            kind: PrintFailureKind::WebView2NotInstalled,
67            detail: "WebView2 Runtime is not installed (required for web page printing)"
68                .to_string(),
69        }
70    }
71
72    #[cfg(feature = "office")]
73    pub fn office_missing(kind: OfficeKind) -> Self {
74        Self {
75            kind: PrintFailureKind::OfficeNotInstalled(kind),
76            detail: format!(
77                "{} is not installed (required for Office document printing)",
78                kind.app_name()
79            ),
80        }
81    }
82}
83
84pub trait StatusSink: Send + Sync {
85    fn on_status(&self, id: &str, status: PrintStatus, error: Option<PrintFailure>);
86}
87
88struct CacheInner {
89    printer_cache: HashMap<String, PrinterDevice>,
90    capabilities_cache: HashMap<String, PrintCapabilities>,
91}
92
93pub(crate) struct PrintCache {
94    inner: tokio::sync::Mutex<CacheInner>,
95}
96
97impl PrintCache {
98    pub fn new() -> Self {
99        Self {
100            inner: tokio::sync::Mutex::new(CacheInner {
101                printer_cache: HashMap::new(),
102                capabilities_cache: HashMap::new(),
103            }),
104        }
105    }
106
107    pub async fn list_printers(&self) -> Result<Vec<String>, String> {
108        let printers =
109            PrinterDevice::all().map_err(|_| "Failed to retrieve printer list".to_string())?;
110        let mut inner = self.inner.lock().await;
111        inner.printer_cache.clear();
112        for p in printers {
113            inner.printer_cache.insert(p.name().to_string(), p);
114        }
115        Ok(inner.printer_cache.keys().cloned().collect())
116    }
117
118    pub async fn printer_capabilities(
119        &self,
120        printer_name: &str,
121    ) -> Result<PrinterCapabilities, String> {
122        let device = self
123            .inner
124            .lock()
125            .await
126            .printer_cache
127            .get(printer_name)
128            .cloned()
129            .ok_or_else(|| format!("Printer not found: {}", printer_name))?;
130        if let Some(caps) = self
131            .inner
132            .lock()
133            .await
134            .capabilities_cache
135            .get(printer_name)
136            .cloned()
137        {
138            return Ok(extract_capabilities(&caps));
139        }
140        let caps = fetch_capabilities_blocking(&device).await?;
141        if let Ok(mut s) = self.inner.try_lock() {
142            s.capabilities_cache.insert(printer_name.to_string(), caps.clone());
143        }
144        Ok(extract_capabilities(&caps))
145    }
146
147    async fn resolve(
148        &self,
149        printer_name: &str,
150    ) -> Result<(PrinterDevice, PrintCapabilities), String> {
151        let (device, capabilities) = {
152            let inner = self.inner.lock().await;
153            (
154                inner.printer_cache.get(printer_name).cloned(),
155                inner.capabilities_cache.get(printer_name).cloned(),
156            )
157        };
158        let device = device.ok_or_else(|| format!("Printer not found: {}", printer_name))?;
159        let capabilities = match capabilities {
160            Some(c) => c,
161            None => {
162                let caps = fetch_capabilities_blocking(&device).await.map_err(|e| {
163                    format!("Failed to fetch capabilities for printer '{}': {}", printer_name, e)
164                })?;
165                if let Ok(mut s) = self.inner.try_lock() {
166                    s.capabilities_cache
167                        .insert(printer_name.to_string(), caps.clone());
168                }
169                caps
170            }
171        };
172        Ok((device, capabilities))
173    }
174}
175
176async fn fetch_capabilities_blocking(device: &PrinterDevice) -> Result<PrintCapabilities, String> {
177    let device = device.clone();
178    tokio::task::spawn_blocking(move || {
179        PrintCapabilities::fetch(&device).map_err(|e| format!("Failed to fetch capabilities: {e}"))
180    })
181    .await
182    .map_err(|e| format!("Capabilities task failed: {e}"))?
183}
184
185pub struct PrintPipeline {
186    cache: PrintCache,
187    html_user_data_folder: Option<PathBuf>,
188}
189
190impl PrintPipeline {
191    pub fn new() -> Self {
192        Self {
193            cache: PrintCache::new(),
194            html_user_data_folder: None,
195        }
196    }
197
198    pub fn with_html_user_data_folder(mut self, folder: impl Into<PathBuf>) -> Self {
199        self.html_user_data_folder = Some(folder.into());
200        self
201    }
202
203    pub async fn list_printers(&self) -> Result<Vec<String>, String> {
204        self.cache.list_printers().await
205    }
206
207    pub async fn printer_capabilities(
208        &self,
209        printer_name: &str,
210    ) -> Result<PrinterCapabilities, String> {
211        self.cache.printer_capabilities(printer_name).await
212    }
213
214    pub async fn print_document(
215        &self,
216        id: &str,
217        url: &str,
218        printer_name: &str,
219        options: &PrintOptions,
220        sink: Arc<dyn StatusSink>,
221    ) {
222        if !url.starts_with("http://") && !url.starts_with("https://") {
223            sink.on_status(
224                id,
225                PrintStatus::Failed,
226                Some(PrintFailure::other("Only HTTP/HTTPS URLs are allowed")),
227            );
228            return;
229        }
230
231        let (printer_device, capabilities) = match self.cache.resolve(printer_name).await {
232            Ok(v) => v,
233            Err(e) => {
234                sink.on_status(id, PrintStatus::Failed, Some(PrintFailure::other(e)));
235                return;
236            }
237        };
238
239        let id = id.to_string();
240        let url = url.to_string();
241        let printer_name = printer_name.to_string();
242        let options = options.clone();
243        let html_user_data_folder = self.html_user_data_folder.clone();
244
245        tokio::spawn(async move {
246            macro_rules! sink_error {
247                ($msg:expr) => {
248                    sink.on_status(&id, PrintStatus::Failed, Some(PrintFailure::other($msg)));
249                };
250            }
251            #[cfg(feature = "html")]
252            macro_rules! sink_fail {
253                ($failure:expr) => {
254                    sink.on_status(&id, PrintStatus::Failed, Some($failure));
255                };
256            }
257            macro_rules! sink_status {
258                ($status:expr) => {
259                    sink.on_status(&id, $status, None);
260                };
261            }
262
263            if let Some(ref url_type) = file_type::detect_type_from_url(&url) {
264                if file_type::is_html_type(url_type) {
265                    sink_status!(PrintStatus::Printing);
266                    #[cfg(feature = "html")]
267                    if !crate::html::webview2_available() {
268                        sink_fail!(PrintFailure::webview2_missing());
269                        return;
270                    }
271                    match print_html(
272                        &url,
273                        &printer_name,
274                        printer_device.clone(),
275                        &capabilities,
276                        &options,
277                        html_user_data_folder.clone(),
278                    )
279                    .await
280                    {
281                        Ok(_) => {
282                            sink_status!(PrintStatus::Success);
283                        }
284                        Err(e) => {
285                            sink_error!(e);
286                        }
287                    }
288                    return;
289                }
290            }
291
292            let (temp_path, file_type) = match download::download_and_detect(&url).await {
293                Ok(t) => t,
294                Err(e) => {
295                    sink_error!(format!("Download failed: {}", e));
296                    return;
297                }
298            };
299
300            if file_type == "html" || file_type == "htm" {
301                sink_status!(PrintStatus::Printing);
302                #[cfg(feature = "html")]
303                if !crate::html::webview2_available() {
304                    drop(temp_path);
305                    sink_fail!(PrintFailure::webview2_missing());
306                    return;
307                }
308                match print_html(
309                    &url,
310                    &printer_name,
311                    printer_device.clone(),
312                    &capabilities,
313                    &options,
314                    html_user_data_folder,
315                )
316                .await
317                {
318                    Ok(_) => {
319                        sink_status!(PrintStatus::Success);
320                    }
321                    Err(e) => {
322                        sink_error!(e);
323                    }
324                }
325                drop(temp_path);
326                return;
327            }
328
329            #[cfg(feature = "office")]
330            if file_type::is_office_type(&file_type) {
331                if let Some(kind) = office::kind_for_file_type(&file_type) {
332                    if !office::is_office_installed(kind).await {
333                        sink_fail!(PrintFailure::office_missing(kind));
334                        drop(temp_path);
335                        return;
336                    }
337                }
338            }
339
340            let print_ticket = match build_print_ticket(&printer_device, &capabilities, &options) {
341                Ok(t) => t,
342                Err(e) => {
343                    sink_error!(format!("Print ticket error: {}", e));
344                    return;
345                }
346            };
347
348            let result = if file_type == "pdf" {
349                sink_status!(PrintStatus::Printing);
350                print_pdf(&temp_path, print_ticket, printer_device.clone()).await
351            } else if file_type::is_image_type(&file_type) {
352                sink_status!(PrintStatus::Printing);
353                let auto_rotate = options.orientation.is_none();
354                print_image(
355                    &temp_path,
356                    print_ticket,
357                    printer_device.clone(),
358                    auto_rotate,
359                )
360                .await
361            } else if file_type == "xps" {
362                sink_status!(PrintStatus::Printing);
363                print_xps(&temp_path, print_ticket, printer_device.clone()).await
364            } else if file_type::is_office_type(&file_type) {
365                print_office(&id, &temp_path, print_ticket, printer_device.clone(), &sink).await
366            } else {
367                Err(format!("Unsupported file type: {}", file_type))
368            };
369
370            drop(temp_path);
371
372            match result {
373                Ok(_) => {
374                    sink_status!(PrintStatus::Success);
375                }
376                Err(e) => {
377                    sink_error!(e);
378                }
379            }
380        });
381    }
382}
383
384impl Default for PrintPipeline {
385    fn default() -> Self {
386        Self::new()
387    }
388}
389
390#[cfg(feature = "html")]
391async fn print_html(
392    url: &str,
393    _printer_name: &str,
394    printer_device: PrinterDevice,
395    capabilities: &PrintCapabilities,
396    options: &PrintOptions,
397    user_data_folder: Option<PathBuf>,
398) -> Result<(), String> {
399    let paper_size = options.page_size.as_ref().and_then(|input| {
400        let normalized = input.trim().to_lowercase();
401        capabilities
402            .page_media_sizes()
403            .find(|o| {
404                o.display_name()
405                    .map(|d| d.trim().to_lowercase() == normalized)
406                    .unwrap_or(false)
407            })
408            .map(|o| {
409                let size = o.size();
410                crate::html::PageSizeInfo {
411                    display_name: o.display_name().unwrap_or("").to_string(),
412                    width_inches: size.width_in_micron() as f64 / 25400.0,
413                    height_inches: size.height_in_micron() as f64 / 25400.0,
414                }
415            })
416    });
417
418    let pdf_temp_path = TempFileBuilder::new()
419        .suffix(".pdf")
420        .tempfile()
421        .map_err(|e| format!("Failed to create temp file: {}", e))?
422        .into_temp_path();
423    let pdf_path = pdf_temp_path.to_path_buf();
424
425    let params = html::HtmlToPdfParams {
426        url: url.to_string(),
427        output_path: pdf_path.clone(),
428        paper_size,
429        orientation: options.orientation.clone(),
430        user_data_folder,
431    };
432
433    tokio::task::spawn_blocking(move || html::html_to_pdf(params))
434        .await
435        .map_err(|e| format!("Blocking task failed: {}", e))??;
436
437    let print_ticket = build_print_ticket(&printer_device, capabilities, options)?;
438
439    print_pdf(&pdf_path, print_ticket, printer_device).await
440}
441
442#[cfg(not(feature = "html"))]
443async fn print_html(
444    _url: &str,
445    _printer_name: &str,
446    _printer_device: PrinterDevice,
447    _capabilities: &PrintCapabilities,
448    _options: &PrintOptions,
449    _user_data_folder: Option<PathBuf>,
450) -> Result<(), String> {
451    Err("HTML printing is not enabled (enable the 'html' feature)".to_string())
452}
453
454async fn print_pdf(
455    path: impl AsRef<Path>,
456    ticket: winprint_ext::ticket::PrintTicket,
457    device: PrinterDevice,
458) -> Result<(), String> {
459    let printer = PdfiumPrinter::new(device);
460    let path = path.as_ref().to_path_buf();
461    tokio::task::spawn_blocking(move || {
462        printer
463            .print(&path, ticket)
464            .map_err(|e| format!("PDF printing failed: {}", e))
465    })
466    .await
467    .map_err(|e| format!("Blocking task failed: {}", e))?
468}
469
470async fn print_image(
471    path: impl AsRef<Path>,
472    ticket: winprint_ext::ticket::PrintTicket,
473    device: PrinterDevice,
474    auto_rotate: bool,
475) -> Result<(), String> {
476    let printer = ImagePrinter::new(device);
477    let path = path.as_ref().to_path_buf();
478    tokio::task::spawn_blocking(move || {
479        printer
480            .print_with_options(&path, ticket, auto_rotate)
481            .map_err(|e| format!("Image printing failed: {}", e))
482    })
483    .await
484    .map_err(|e| format!("Blocking task failed: {}", e))?
485}
486
487async fn print_xps(
488    path: impl AsRef<Path>,
489    ticket: winprint_ext::ticket::PrintTicket,
490    device: PrinterDevice,
491) -> Result<(), String> {
492    let printer = XpsPrinter::new(device);
493    let path = path.as_ref().to_path_buf();
494    tokio::task::spawn_blocking(move || {
495        printer
496            .print(&path, ticket)
497            .map_err(|e| format!("XPS printing failed: {}", e))
498    })
499    .await
500    .map_err(|e| format!("Blocking task failed: {}", e))?
501}
502
503#[cfg(feature = "office")]
504struct TempFileGuard(PathBuf);
505
506#[cfg(feature = "office")]
507impl Drop for TempFileGuard {
508    fn drop(&mut self) {
509        if let Err(e) = remove_file(&self.0) {
510            eprintln!(
511                "[TempFileGuard] Failed to remove '{}': {}",
512                self.0.display(),
513                e
514            );
515        }
516    }
517}
518
519#[cfg(feature = "office")]
520static OFFICE_CONVERSION_SEMAPHORE: std::sync::OnceLock<Semaphore> =
521    std::sync::OnceLock::new();
522
523#[cfg(feature = "office")]
524fn get_office_semaphore() -> &'static Semaphore {
525    OFFICE_CONVERSION_SEMAPHORE.get_or_init(|| Semaphore::new(1))
526}
527
528#[cfg(feature = "office")]
529async fn print_office(
530    id: &str,
531    input_path: impl AsRef<Path>,
532    ticket: winprint_ext::ticket::PrintTicket,
533    device: PrinterDevice,
534    sink: &Arc<dyn StatusSink>,
535) -> Result<(), String> {
536    let input_path = input_path.as_ref();
537    let input_str = input_path
538        .to_str()
539        .ok_or_else(|| "Input path contains invalid UTF-8".to_string())?;
540    if let Some(kind) = office::kind_from_path(input_str) {
541        if !office::is_office_installed(kind).await {
542            return Err(format!(
543                "{} is not installed. Office printing requires Microsoft Office 2010+.",
544                office::kind_display_name(kind)
545            ));
546        }
547    }
548    let _permit = get_office_semaphore()
549        .acquire()
550        .await
551        .map_err(|_| "Office conversion semaphore acquire failed".to_string())?;
552
553    sink.on_status(id, PrintStatus::Printing, None);
554
555    let pdf_path = input_path.with_extension("pdf");
556    let _pdf_guard = TempFileGuard(pdf_path.clone());
557
558    let output_str = pdf_path
559        .to_str()
560        .ok_or_else(|| "Output path contains invalid UTF-8".to_string())?;
561
562    office::convert_office_to_pdf(input_str, output_str)
563        .await
564        .map_err(|e| format!("Office to PDF conversion failed: {}", e))?;
565
566    print_pdf(pdf_path.clone(), ticket, device).await
567}
568
569#[cfg(not(feature = "office"))]
570async fn print_office(
571    _id: &str,
572    _input_path: impl AsRef<Path>,
573    _ticket: winprint_ext::ticket::PrintTicket,
574    _device: PrinterDevice,
575    _sink: &Arc<dyn StatusSink>,
576) -> Result<(), String> {
577    Err("Office printing is not enabled (enable the 'office' feature)".to_string())
578}
579