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