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