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