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