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