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            macro_rules! sink_fail {
252                ($failure:expr) => {
253                    sink.on_status(&id, PrintStatus::Failed, Some($failure));
254                };
255            }
256            macro_rules! sink_status {
257                ($status:expr) => {
258                    sink.on_status(&id, $status, None);
259                };
260            }
261
262            if let Some(ref url_type) = file_type::detect_type_from_url(&url) {
263                if file_type::is_html_type(url_type) {
264                    sink_status!(PrintStatus::Printing);
265                    #[cfg(feature = "html")]
266                    if !crate::html::webview2_available() {
267                        sink_fail!(PrintFailure::webview2_missing());
268                        return;
269                    }
270                    match print_html(
271                        &url,
272                        &printer_name,
273                        printer_device.clone(),
274                        &capabilities,
275                        &options,
276                        html_user_data_folder.clone(),
277                    )
278                    .await
279                    {
280                        Ok(_) => {
281                            sink_status!(PrintStatus::Success);
282                        }
283                        Err(e) => {
284                            sink_error!(e);
285                        }
286                    }
287                    return;
288                }
289            }
290
291            let (temp_path, file_type) = match download::download_and_detect(&url).await {
292                Ok(t) => t,
293                Err(e) => {
294                    sink_error!(format!("Download failed: {}", e));
295                    return;
296                }
297            };
298
299            if file_type == "html" || file_type == "htm" {
300                sink_status!(PrintStatus::Printing);
301                #[cfg(feature = "html")]
302                if !crate::html::webview2_available() {
303                    drop(temp_path);
304                    sink_fail!(PrintFailure::webview2_missing());
305                    return;
306                }
307                match print_html(
308                    &url,
309                    &printer_name,
310                    printer_device.clone(),
311                    &capabilities,
312                    &options,
313                    html_user_data_folder,
314                )
315                .await
316                {
317                    Ok(_) => {
318                        sink_status!(PrintStatus::Success);
319                    }
320                    Err(e) => {
321                        sink_error!(e);
322                    }
323                }
324                drop(temp_path);
325                return;
326            }
327
328            #[cfg(feature = "office")]
329            if file_type::is_office_type(&file_type) {
330                if let Some(kind) = office::kind_for_file_type(&file_type) {
331                    if !office::is_office_installed(kind).await {
332                        sink_fail!(PrintFailure::office_missing(kind));
333                        drop(temp_path);
334                        return;
335                    }
336                }
337            }
338
339            let print_ticket = match build_print_ticket(&printer_device, &capabilities, &options) {
340                Ok(t) => t,
341                Err(e) => {
342                    sink_error!(format!("Print ticket error: {}", e));
343                    return;
344                }
345            };
346
347            let result = if file_type == "pdf" {
348                sink_status!(PrintStatus::Printing);
349                print_pdf(&temp_path, print_ticket, printer_device.clone()).await
350            } else if file_type::is_image_type(&file_type) {
351                sink_status!(PrintStatus::Printing);
352                let auto_rotate = options.orientation.is_none();
353                print_image(
354                    &temp_path,
355                    print_ticket,
356                    printer_device.clone(),
357                    auto_rotate,
358                )
359                .await
360            } else if file_type == "xps" {
361                sink_status!(PrintStatus::Printing);
362                print_xps(&temp_path, print_ticket, printer_device.clone()).await
363            } else if file_type::is_office_type(&file_type) {
364                print_office(&id, &temp_path, print_ticket, printer_device.clone(), &sink).await
365            } else {
366                Err(format!("Unsupported file type: {}", file_type))
367            };
368
369            drop(temp_path);
370
371            match result {
372                Ok(_) => {
373                    sink_status!(PrintStatus::Success);
374                }
375                Err(e) => {
376                    sink_error!(e);
377                }
378            }
379        });
380    }
381}
382
383impl Default for PrintPipeline {
384    fn default() -> Self {
385        Self::new()
386    }
387}
388
389#[cfg(feature = "html")]
390async fn print_html(
391    url: &str,
392    _printer_name: &str,
393    printer_device: PrinterDevice,
394    capabilities: &PrintCapabilities,
395    options: &PrintOptions,
396    user_data_folder: Option<PathBuf>,
397) -> Result<(), String> {
398    let paper_size = options.page_size.as_ref().and_then(|input| {
399        let normalized = input.trim().to_lowercase();
400        capabilities
401            .page_media_sizes()
402            .find(|o| {
403                o.display_name()
404                    .map(|d| d.trim().to_lowercase() == normalized)
405                    .unwrap_or(false)
406            })
407            .map(|o| {
408                let size = o.size();
409                crate::html::PageSizeInfo {
410                    display_name: o.display_name().unwrap_or("").to_string(),
411                    width_inches: size.width_in_micron() as f64 / 25400.0,
412                    height_inches: size.height_in_micron() as f64 / 25400.0,
413                }
414            })
415    });
416
417    let pdf_temp_path = TempFileBuilder::new()
418        .suffix(".pdf")
419        .tempfile()
420        .map_err(|e| format!("Failed to create temp file: {}", e))?
421        .into_temp_path();
422    let pdf_path = pdf_temp_path.to_path_buf();
423
424    let params = html::HtmlToPdfParams {
425        url: url.to_string(),
426        output_path: pdf_path.clone(),
427        paper_size,
428        orientation: options.orientation.clone(),
429        user_data_folder,
430    };
431
432    tokio::task::spawn_blocking(move || html::html_to_pdf(params))
433        .await
434        .map_err(|e| format!("Blocking task failed: {}", e))??;
435
436    let print_ticket = build_print_ticket(&printer_device, capabilities, options)?;
437
438    print_pdf(&pdf_path, print_ticket, printer_device).await
439}
440
441#[cfg(not(feature = "html"))]
442async fn print_html(
443    _url: &str,
444    _printer_name: &str,
445    _printer_device: PrinterDevice,
446    _capabilities: &PrintCapabilities,
447    _options: &PrintOptions,
448    _user_data_folder: Option<PathBuf>,
449) -> Result<(), String> {
450    Err("HTML printing is not enabled (enable the 'html' feature)".to_string())
451}
452
453async fn print_pdf(
454    path: impl AsRef<Path>,
455    ticket: winprint_ext::ticket::PrintTicket,
456    device: PrinterDevice,
457) -> Result<(), String> {
458    let printer = PdfiumPrinter::new(device);
459    let path = path.as_ref().to_path_buf();
460    tokio::task::spawn_blocking(move || {
461        printer
462            .print(&path, ticket)
463            .map_err(|e| format!("PDF printing failed: {}", e))
464    })
465    .await
466    .map_err(|e| format!("Blocking task failed: {}", e))?
467}
468
469async fn print_image(
470    path: impl AsRef<Path>,
471    ticket: winprint_ext::ticket::PrintTicket,
472    device: PrinterDevice,
473    auto_rotate: bool,
474) -> Result<(), String> {
475    let printer = ImagePrinter::new(device);
476    let path = path.as_ref().to_path_buf();
477    tokio::task::spawn_blocking(move || {
478        printer
479            .print_with_options(&path, ticket, auto_rotate)
480            .map_err(|e| format!("Image printing failed: {}", e))
481    })
482    .await
483    .map_err(|e| format!("Blocking task failed: {}", e))?
484}
485
486async fn print_xps(
487    path: impl AsRef<Path>,
488    ticket: winprint_ext::ticket::PrintTicket,
489    device: PrinterDevice,
490) -> Result<(), String> {
491    let printer = XpsPrinter::new(device);
492    let path = path.as_ref().to_path_buf();
493    tokio::task::spawn_blocking(move || {
494        printer
495            .print(&path, ticket)
496            .map_err(|e| format!("XPS printing failed: {}", e))
497    })
498    .await
499    .map_err(|e| format!("Blocking task failed: {}", e))?
500}
501
502#[cfg(feature = "office")]
503struct TempFileGuard(PathBuf);
504
505#[cfg(feature = "office")]
506impl Drop for TempFileGuard {
507    fn drop(&mut self) {
508        if let Err(e) = remove_file(&self.0) {
509            eprintln!(
510                "[TempFileGuard] Failed to remove '{}': {}",
511                self.0.display(),
512                e
513            );
514        }
515    }
516}
517
518#[cfg(feature = "office")]
519static OFFICE_CONVERSION_SEMAPHORE: std::sync::OnceLock<Semaphore> =
520    std::sync::OnceLock::new();
521
522#[cfg(feature = "office")]
523fn get_office_semaphore() -> &'static Semaphore {
524    OFFICE_CONVERSION_SEMAPHORE.get_or_init(|| Semaphore::new(1))
525}
526
527#[cfg(feature = "office")]
528async fn print_office(
529    id: &str,
530    input_path: impl AsRef<Path>,
531    ticket: winprint_ext::ticket::PrintTicket,
532    device: PrinterDevice,
533    sink: &Arc<dyn StatusSink>,
534) -> Result<(), String> {
535    let input_path = input_path.as_ref();
536    let input_str = input_path
537        .to_str()
538        .ok_or_else(|| "Input path contains invalid UTF-8".to_string())?;
539    if let Some(kind) = office::kind_from_path(input_str) {
540        if !office::is_office_installed(kind).await {
541            return Err(format!(
542                "{} is not installed. Office printing requires Microsoft Office 2010+.",
543                office::kind_display_name(kind)
544            ));
545        }
546    }
547    let _permit = get_office_semaphore()
548        .acquire()
549        .await
550        .map_err(|_| "Office conversion semaphore acquire failed".to_string())?;
551
552    sink.on_status(id, PrintStatus::Printing, None);
553
554    let pdf_path = input_path.with_extension("pdf");
555    let _pdf_guard = TempFileGuard(pdf_path.clone());
556
557    let output_str = pdf_path
558        .to_str()
559        .ok_or_else(|| "Output path contains invalid UTF-8".to_string())?;
560
561    office::convert_office_to_pdf(input_str, output_str)
562        .await
563        .map_err(|e| format!("Office to PDF conversion failed: {}", e))?;
564
565    print_pdf(pdf_path.clone(), ticket, device).await
566}
567
568#[cfg(not(feature = "office"))]
569async fn print_office(
570    _id: &str,
571    _input_path: impl AsRef<Path>,
572    _ticket: winprint_ext::ticket::PrintTicket,
573    _device: PrinterDevice,
574    _sink: &Arc<dyn StatusSink>,
575) -> Result<(), String> {
576    Err("Office printing is not enabled (enable the 'office' feature)".to_string())
577}
578