1use 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#[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 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