html2pdf_api/service/
pdf.rs

1//! Core PDF generation service (framework-agnostic).
2//!
3//! This module contains the core PDF generation logic that is shared across
4//! all web framework integrations. The functions here are **synchronous/blocking**
5//! and should be called from within a blocking context (e.g., `tokio::task::spawn_blocking`,
6//! `actix_web::web::block`, etc.).
7//!
8//! # Architecture
9//!
10//! ```text
11//! ┌─────────────────────────────────────────────────────────────────┐
12//! │                    Framework Integration                        │
13//! │              (Actix-web / Rocket / Axum)                        │
14//! └─────────────────────────┬───────────────────────────────────────┘
15//!                           │ async context
16//!                           ▼
17//! ┌─────────────────────────────────────────────────────────────────┐
18//! │              spawn_blocking / web::block                        │
19//! └─────────────────────────┬───────────────────────────────────────┘
20//!                           │ blocking context
21//!                           ▼
22//! ┌─────────────────────────────────────────────────────────────────┐
23//! │                  This Module (pdf.rs)                           │
24//! │  ┌─────────────────┐  ┌─────────────────┐  ┌─────────────────┐  │
25//! │  │generate_pdf_    │  │generate_pdf_    │  │get_pool_stats   │  │
26//! │  │from_url         │  │from_html        │  │                 │  │
27//! │  └────────┬────────┘  └────────┬────────┘  └─────────────────┘  │
28//! │           │                    │                                │
29//! │           └──────────┬─────────┘                                │
30//! │                      ▼                                          │
31//! │           ┌─────────────────────┐                               │
32//! │           │generate_pdf_internal│                               │
33//! │           └──────────┬──────────┘                               │
34//! └──────────────────────┼──────────────────────────────────────────┘
35//!                        │
36//!                        ▼
37//! ┌─────────────────────────────────────────────────────────────────┐
38//! │                    BrowserPool                                  │
39//! │                 (headless_chrome)                               │
40//! └─────────────────────────────────────────────────────────────────┘
41//! ```
42//!
43//! # Thread Safety
44//!
45//! All functions in this module are designed to be called from multiple threads
46//! concurrently. The browser pool is protected by a `Mutex`, and each PDF
47//! generation operation acquires a browser, uses it, and returns it to the pool
48//! automatically via RAII.
49//!
50//! # Blocking Behavior
51//!
52//! **Important:** These functions block the calling thread. In an async context,
53//! always wrap calls in a blocking task:
54//!
55//! ```rust,ignore
56//! // ✅ Correct: Using spawn_blocking
57//! let result = tokio::task::spawn_blocking(move || {
58//!     generate_pdf_from_url(&pool, &request)
59//! }).await?;
60//!
61//! // ❌ Wrong: Calling directly in async context
62//! // This will block the async runtime!
63//! let result = generate_pdf_from_url(&pool, &request);
64//! ```
65//!
66//! # Usage Examples
67//!
68//! ## Basic URL to PDF Conversion
69//!
70//! ```rust,ignore
71//! use html2pdf_api::service::{generate_pdf_from_url, PdfFromUrlRequest};
72//! use std::sync::Mutex;
73//!
74//! // Assuming `pool` is a Mutex<BrowserPool>
75//! let request = PdfFromUrlRequest {
76//!     url: "https://example.com".to_string(),
77//!     ..Default::default()
78//! };
79//!
80//! // In a blocking context:
81//! let response = generate_pdf_from_url(&pool, &request)?;
82//! println!("Generated PDF: {} bytes", response.data.len());
83//! ```
84//!
85//! ## HTML to PDF Conversion
86//!
87//! ```rust,ignore
88//! use html2pdf_api::service::{generate_pdf_from_html, PdfFromHtmlRequest};
89//!
90//! let request = PdfFromHtmlRequest {
91//!     html: "<html><body><h1>Hello World</h1></body></html>".to_string(),
92//!     filename: Some("hello.pdf".to_string()),
93//!     ..Default::default()
94//! };
95//!
96//! let response = generate_pdf_from_html(&pool, &request)?;
97//! std::fs::write("hello.pdf", &response.data)?;
98//! ```
99//!
100//! ## With Async Web Framework
101//!
102//! ```rust,ignore
103//! use actix_web::{web, HttpResponse};
104//! use html2pdf_api::service::{generate_pdf_from_url, PdfFromUrlRequest};
105//!
106//! async fn handler(
107//!     pool: web::Data<SharedPool>,
108//!     query: web::Query<PdfFromUrlRequest>,
109//! ) -> HttpResponse {
110//!     let pool = pool.into_inner();
111//!     let request = query.into_inner();
112//!
113//!     let result = web::block(move || {
114//!         generate_pdf_from_url(&pool, &request)
115//!     }).await;
116//!
117//!     match result {
118//!         Ok(Ok(pdf)) => HttpResponse::Ok()
119//!             .content_type("application/pdf")
120//!             .body(pdf.data),
121//!         Ok(Err(e)) => HttpResponse::BadRequest().body(e.to_string()),
122//!         Err(e) => HttpResponse::InternalServerError().body(e.to_string()),
123//!     }
124//! }
125//! ```
126//!
127//! # Performance Considerations
128//!
129//! | Operation | Typical Duration | Notes |
130//! |-----------|------------------|-------|
131//! | Pool lock acquisition | < 1ms | Fast, non-blocking |
132//! | Browser checkout | < 1ms | If browser available |
133//! | Browser creation | 500ms - 2s | If pool needs to create new browser |
134//! | Page navigation | 100ms - 10s | Depends on target page |
135//! | JavaScript wait | 0 - 15s | Configurable via `waitsecs` |
136//! | PDF generation | 100ms - 5s | Depends on page complexity |
137//! | Tab cleanup | < 100ms | Best effort, non-blocking |
138//!
139//! # Error Handling
140//!
141//! All functions return `Result<T, PdfServiceError>`. Errors are categorized
142//! and include appropriate HTTP status codes. See [`PdfServiceError`] for
143//! the complete error taxonomy.
144//!
145//! [`PdfServiceError`]: crate::service::PdfServiceError
146
147use headless_chrome::types::PrintToPdfOptions;
148use std::sync::Mutex;
149use std::time::{Duration, Instant};
150
151use crate::handle::BrowserHandle;
152use crate::pool::BrowserPool;
153use crate::service::types::*;
154
155// ============================================================================
156// Constants
157// ============================================================================
158
159/// Default timeout for the entire PDF generation operation in seconds.
160///
161/// This timeout encompasses the complete operation including:
162/// - Browser acquisition from pool
163/// - Page navigation
164/// - JavaScript execution wait
165/// - PDF rendering
166/// - Tab cleanup
167///
168/// If the operation exceeds this duration, a [`PdfServiceError::Timeout`]
169/// error is returned.
170///
171/// # Default Value
172///
173/// `60` seconds - sufficient for most web pages, including those with
174/// heavy JavaScript and external resources.
175///
176/// # Customization
177///
178/// This constant is used by framework integrations for their timeout wrappers.
179/// To customize, create your own timeout wrapper around the service functions.
180///
181/// ```rust,ignore
182/// use std::time::Duration;
183/// use tokio::time::timeout;
184///
185/// let custom_timeout = Duration::from_secs(120); // 2 minutes
186///
187/// let result = timeout(custom_timeout, async {
188///     tokio::task::spawn_blocking(move || {
189///         generate_pdf_from_url(&pool, &request)
190///     }).await
191/// }).await;
192/// ```
193pub const DEFAULT_TIMEOUT_SECS: u64 = 60;
194
195/// Default wait time for JavaScript execution in seconds.
196///
197/// After page navigation completes, the service waits for JavaScript to finish
198/// rendering dynamic content. This constant defines the default wait time when
199/// not specified in the request.
200///
201/// # Behavior
202///
203/// During the wait period, the service polls every 200ms for `window.isPageDone === true`.
204/// If the page sets this flag, PDF generation proceeds immediately. Otherwise,
205/// the full wait duration elapses before generating the PDF.
206///
207/// # Default Value
208///
209/// `5` seconds - balances between allowing time for JavaScript execution
210/// and not waiting unnecessarily for simple pages.
211///
212/// # Recommendations
213///
214/// | Page Type | Recommended Wait |
215/// |-----------|------------------|
216/// | Static HTML | 1-2 seconds |
217/// | Light JavaScript (vanilla JS, jQuery) | 3-5 seconds |
218/// | Heavy SPA (React, Vue, Angular) | 5-10 seconds |
219/// | Complex visualizations (D3, charts) | 10-15 seconds |
220/// | Real-time data loading | 10-20 seconds |
221pub const DEFAULT_WAIT_SECS: u64 = 5;
222
223/// Polling interval for JavaScript completion check in milliseconds.
224///
225/// When waiting for JavaScript to complete, the service checks for
226/// `window.isPageDone === true` at this interval.
227///
228/// # Trade-offs
229///
230/// - **Shorter interval**: More responsive but higher CPU usage
231/// - **Longer interval**: Lower CPU usage but may overshoot ready state
232///
233/// # Default Value
234///
235/// `200` milliseconds - provides good responsiveness without excessive polling.
236const JS_POLL_INTERVAL_MS: u64 = 200;
237
238// ============================================================================
239// Public API - Core PDF Generation Functions
240// ============================================================================
241
242/// Generate a PDF from a URL.
243///
244/// Navigates to the specified URL using a browser from the pool, waits for
245/// JavaScript execution, and generates a PDF of the rendered page.
246///
247/// # Thread Safety
248///
249/// This function is thread-safe and can be called concurrently from multiple
250/// threads. The browser pool mutex ensures safe access to shared resources.
251///
252/// # Blocking Behavior
253///
254/// **This function blocks the calling thread.** In async contexts, wrap it
255/// in `tokio::task::spawn_blocking`, `actix_web::web::block`, or similar.
256///
257/// # Arguments
258///
259/// * `pool` - Reference to the mutex-wrapped browser pool. The mutex is held
260///   only briefly during browser checkout; PDF generation occurs outside the lock.
261/// * `request` - PDF generation parameters. See [`PdfFromUrlRequest`] for details.
262///
263/// # Returns
264///
265/// * `Ok(PdfResponse)` - Successfully generated PDF with binary data and metadata
266/// * `Err(PdfServiceError)` - Error with details about what went wrong
267///
268/// # Errors
269///
270/// | Error | Cause | Resolution |
271/// |-------|-------|------------|
272/// | [`InvalidUrl`] | URL is empty or malformed | Provide valid HTTP/HTTPS URL |
273/// | [`PoolLockFailed`] | Mutex poisoned | Restart service |
274/// | [`BrowserUnavailable`] | Pool exhausted | Retry or increase pool size |
275/// | [`TabCreationFailed`] | Browser issue | Automatic recovery |
276/// | [`NavigationFailed`] | URL unreachable | Check URL accessibility |
277/// | [`NavigationTimeout`] | Page too slow | Increase timeout or optimize page |
278/// | [`PdfGenerationFailed`] | Rendering issue | Simplify page or check content |
279///
280/// [`InvalidUrl`]: PdfServiceError::InvalidUrl
281/// [`PoolLockFailed`]: PdfServiceError::PoolLockFailed
282/// [`BrowserUnavailable`]: PdfServiceError::BrowserUnavailable
283/// [`TabCreationFailed`]: PdfServiceError::TabCreationFailed
284/// [`NavigationFailed`]: PdfServiceError::NavigationFailed
285/// [`NavigationTimeout`]: PdfServiceError::NavigationTimeout
286/// [`PdfGenerationFailed`]: PdfServiceError::PdfGenerationFailed
287///
288/// # Examples
289///
290/// ## Basic Usage
291///
292/// ```rust,ignore
293/// use html2pdf_api::service::{generate_pdf_from_url, PdfFromUrlRequest};
294///
295/// let request = PdfFromUrlRequest {
296///     url: "https://example.com".to_string(),
297///     ..Default::default()
298/// };
299///
300/// let response = generate_pdf_from_url(&pool, &request)?;
301/// assert!(response.data.starts_with(b"%PDF-")); // Valid PDF header
302/// ```
303///
304/// ## With Custom Options
305///
306/// ```rust,ignore
307/// let request = PdfFromUrlRequest {
308///     url: "https://example.com/report".to_string(),
309///     filename: Some("quarterly-report.pdf".to_string()),
310///     landscape: Some(true),      // Wide tables
311///     waitsecs: Some(10),         // Complex charts
312///     download: Some(true),       // Force download
313///     print_background: Some(true),
314/// };
315///
316/// let response = generate_pdf_from_url(&pool, &request)?;
317/// println!("Generated {} with {} bytes", response.filename, response.size());
318/// ```
319///
320/// ## Error Handling
321///
322/// ```rust,ignore
323/// match generate_pdf_from_url(&pool, &request) {
324///     Ok(pdf) => {
325///         // Success - use pdf.data
326///     }
327///     Err(PdfServiceError::InvalidUrl(msg)) => {
328///         // Client error - return 400
329///         eprintln!("Bad URL: {}", msg);
330///     }
331///     Err(PdfServiceError::BrowserUnavailable(_)) => {
332///         // Transient error - retry
333///         std::thread::sleep(Duration::from_secs(1));
334///     }
335///     Err(e) => {
336///         // Other error
337///         eprintln!("PDF generation failed: {}", e);
338///     }
339/// }
340/// ```
341///
342/// # Performance
343///
344/// Typical execution time breakdown for a moderately complex page:
345///
346/// ```text
347/// ┌────────────────────────────────────────────────────────────────┐
348/// │ Pool lock + browser checkout                          ~1ms    │
349/// │ ├─────────────────────────────────────────────────────────────┤
350/// │ Tab creation                                          ~50ms   │
351/// │ ├─────────────────────────────────────────────────────────────┤
352/// │ Navigation + page load                                ~500ms  │
353/// │ ├─────────────────────────────────────────────────────────────┤
354/// │ JavaScript wait (configurable)                        ~5000ms │
355/// │ ├─────────────────────────────────────────────────────────────┤
356/// │ PDF rendering                                         ~200ms  │
357/// │ ├─────────────────────────────────────────────────────────────┤
358/// │ Tab cleanup                                           ~50ms   │
359/// └────────────────────────────────────────────────────────────────┘
360/// Total: ~5.8 seconds (dominated by JS wait)
361/// ```
362pub fn generate_pdf_from_url(
363    pool: &Mutex<BrowserPool>,
364    request: &PdfFromUrlRequest,
365) -> Result<PdfResponse, PdfServiceError> {
366    // Validate URL before acquiring browser
367    let url = validate_url(&request.url)?;
368
369    log::debug!(
370        "Generating PDF from URL: {} (landscape={}, wait={}s)",
371        url,
372        request.is_landscape(),
373        request.wait_duration().as_secs()
374    );
375
376    // Acquire browser from pool (lock held briefly)
377    let browser = acquire_browser(pool)?;
378
379    // Generate PDF (lock released, browser returned via RAII on completion/error)
380    let pdf_data = generate_pdf_internal(
381        &browser,
382        &url,
383        request.wait_duration(),
384        request.is_landscape(),
385        request.print_background(),
386    )?;
387
388    log::info!(
389        "✅ PDF generated successfully from URL: {} ({} bytes)",
390        url,
391        pdf_data.len()
392    );
393
394    Ok(PdfResponse::new(
395        pdf_data,
396        request.filename_or_default(),
397        request.is_download(),
398    ))
399}
400
401/// Generate a PDF from HTML content.
402///
403/// Loads the provided HTML content into a browser tab using a data URL,
404/// waits for any JavaScript execution, and generates a PDF.
405///
406/// # Thread Safety
407///
408/// This function is thread-safe and can be called concurrently from multiple
409/// threads. See [`generate_pdf_from_url`] for details.
410///
411/// # Blocking Behavior
412///
413/// **This function blocks the calling thread.** See [`generate_pdf_from_url`]
414/// for guidance on async usage.
415///
416/// # How It Works
417///
418/// The HTML content is converted to a data URL:
419///
420/// ```text
421/// data:text/html;charset=utf-8,<encoded-html-content>
422/// ```
423///
424/// This allows loading HTML directly without a web server. The browser
425/// renders the HTML as if it were loaded from a regular URL.
426///
427/// # Arguments
428///
429/// * `pool` - Reference to the mutex-wrapped browser pool
430/// * `request` - HTML content and generation parameters. See [`PdfFromHtmlRequest`].
431///
432/// # Returns
433///
434/// * `Ok(PdfResponse)` - Successfully generated PDF
435/// * `Err(PdfServiceError)` - Error details
436///
437/// # Errors
438///
439/// | Error | Cause | Resolution |
440/// |-------|-------|------------|
441/// | [`EmptyHtml`] | HTML content is empty/whitespace | Provide HTML content |
442/// | [`PoolLockFailed`] | Mutex poisoned | Restart service |
443/// | [`BrowserUnavailable`] | Pool exhausted | Retry or increase pool size |
444/// | [`NavigationFailed`] | HTML parsing issue | Check HTML validity |
445/// | [`PdfGenerationFailed`] | Rendering issue | Simplify HTML |
446///
447/// [`EmptyHtml`]: PdfServiceError::EmptyHtml
448/// [`PoolLockFailed`]: PdfServiceError::PoolLockFailed
449/// [`BrowserUnavailable`]: PdfServiceError::BrowserUnavailable
450/// [`NavigationFailed`]: PdfServiceError::NavigationFailed
451/// [`PdfGenerationFailed`]: PdfServiceError::PdfGenerationFailed
452///
453/// # Limitations
454///
455/// ## External Resources
456///
457/// Since HTML is loaded via data URL, relative URLs don't work:
458///
459/// ```html
460/// <!-- ❌ Won't work - relative URL -->
461/// <img src="/images/logo.png">
462///
463/// <!-- ✅ Works - absolute URL -->
464/// <img src="https://example.com/images/logo.png">
465///
466/// <!-- ✅ Works - inline base64 -->
467/// <img src="data:image/png;base64,iVBORw0KGgo...">
468/// ```
469///
470/// ## Size Limits
471///
472/// Data URLs have browser-specific size limits. For very large HTML documents
473/// (> 1MB), consider:
474/// - Hosting the HTML on a temporary server
475/// - Using [`generate_pdf_from_url`] instead
476/// - Splitting into multiple PDFs
477///
478/// # Examples
479///
480/// ## Simple HTML
481///
482/// ```rust,ignore
483/// use html2pdf_api::service::{generate_pdf_from_html, PdfFromHtmlRequest};
484///
485/// let request = PdfFromHtmlRequest {
486///     html: "<h1>Hello World</h1><p>This is a test.</p>".to_string(),
487///     ..Default::default()
488/// };
489///
490/// let response = generate_pdf_from_html(&pool, &request)?;
491/// std::fs::write("output.pdf", &response.data)?;
492/// ```
493///
494/// ## Complete Document with Styling
495///
496/// ```rust,ignore
497/// let html = r#"
498/// <!DOCTYPE html>
499/// <html>
500/// <head>
501///     <meta charset="UTF-8">
502///     <style>
503///         body {
504///             font-family: 'Arial', sans-serif;
505///             margin: 40px;
506///             color: #333;
507///         }
508///         h1 {
509///             color: #0066cc;
510///             border-bottom: 2px solid #0066cc;
511///             padding-bottom: 10px;
512///         }
513///         table {
514///             width: 100%;
515///             border-collapse: collapse;
516///             margin-top: 20px;
517///         }
518///         th, td {
519///             border: 1px solid #ddd;
520///             padding: 12px;
521///             text-align: left;
522///         }
523///         th {
524///             background-color: #f5f5f5;
525///         }
526///     </style>
527/// </head>
528/// <body>
529///     <h1>Monthly Report</h1>
530///     <p>Generated on: 2024-01-15</p>
531///     <table>
532///         <tr><th>Metric</th><th>Value</th></tr>
533///         <tr><td>Revenue</td><td>$50,000</td></tr>
534///         <tr><td>Users</td><td>1,234</td></tr>
535///     </table>
536/// </body>
537/// </html>
538/// "#;
539///
540/// let request = PdfFromHtmlRequest {
541///     html: html.to_string(),
542///     filename: Some("monthly-report.pdf".to_string()),
543///     print_background: Some(true), // Include styled backgrounds
544///     ..Default::default()
545/// };
546///
547/// let response = generate_pdf_from_html(&pool, &request)?;
548/// ```
549///
550/// ## With Embedded Images
551///
552/// ```rust,ignore
553/// // Base64 encode an image
554/// let image_base64 = base64::encode(std::fs::read("logo.png")?);
555///
556/// let html = format!(r#"
557/// <!DOCTYPE html>
558/// <html>
559/// <body>
560///     <img src="data:image/png;base64,{}" alt="Logo">
561///     <h1>Company Report</h1>
562/// </body>
563/// </html>
564/// "#, image_base64);
565///
566/// let request = PdfFromHtmlRequest {
567///     html,
568///     ..Default::default()
569/// };
570///
571/// let response = generate_pdf_from_html(&pool, &request)?;
572/// ```
573pub fn generate_pdf_from_html(
574    pool: &Mutex<BrowserPool>,
575    request: &PdfFromHtmlRequest,
576) -> Result<PdfResponse, PdfServiceError> {
577    // Validate HTML content
578    if request.html.trim().is_empty() {
579        log::warn!("Empty HTML content provided");
580        return Err(PdfServiceError::EmptyHtml);
581    }
582
583    log::debug!(
584        "Generating PDF from HTML ({} bytes, landscape={}, wait={}s)",
585        request.html.len(),
586        request.is_landscape(),
587        request.wait_duration().as_secs()
588    );
589
590    // Acquire browser from pool
591    let browser = acquire_browser(pool)?;
592
593    // Convert HTML to data URL
594    // Using percent-encoding to handle special characters
595    let data_url = format!(
596        "data:text/html;charset=utf-8,{}",
597        urlencoding::encode(&request.html)
598    );
599
600    log::trace!("Data URL length: {} bytes", data_url.len());
601
602    // Generate PDF
603    let pdf_data = generate_pdf_internal(
604        &browser,
605        &data_url,
606        request.wait_duration(),
607        request.is_landscape(),
608        request.print_background(),
609    )?;
610
611    log::info!(
612        "✅ PDF generated successfully from HTML ({} bytes input → {} bytes output)",
613        request.html.len(),
614        pdf_data.len()
615    );
616
617    Ok(PdfResponse::new(
618        pdf_data,
619        request.filename_or_default(),
620        request.is_download(),
621    ))
622}
623
624/// Get current browser pool statistics.
625///
626/// Returns real-time metrics about the browser pool state including
627/// available browsers, active browsers, and total count.
628///
629/// # Thread Safety
630///
631/// This function briefly acquires the pool lock to read statistics.
632/// It's safe to call frequently for monitoring purposes.
633///
634/// # Blocking Behavior
635///
636/// This function blocks briefly (< 1ms typically) while holding the
637/// pool lock. It's generally safe to call from async contexts directly,
638/// but for consistency, you may still wrap it in a blocking task.
639///
640/// # Arguments
641///
642/// * `pool` - Reference to the mutex-wrapped browser pool
643///
644/// # Returns
645///
646/// * `Ok(PoolStatsResponse)` - Current pool statistics
647/// * `Err(PdfServiceError::PoolLockFailed)` - If mutex is poisoned
648///
649/// # Examples
650///
651/// ## Basic Usage
652///
653/// ```rust,ignore
654/// use html2pdf_api::service::get_pool_stats;
655///
656/// let stats = get_pool_stats(&pool)?;
657/// println!("Available: {}", stats.available);
658/// println!("Active: {}", stats.active);
659/// println!("Total: {}", stats.total);
660/// ```
661///
662/// ## Monitoring Integration
663///
664/// ```rust,ignore
665/// use prometheus::{Gauge, register_gauge};
666///
667/// lazy_static! {
668///     static ref POOL_AVAILABLE: Gauge = register_gauge!(
669///         "browser_pool_available",
670///         "Number of available browsers in pool"
671///     ).unwrap();
672///     static ref POOL_ACTIVE: Gauge = register_gauge!(
673///         "browser_pool_active",
674///         "Number of active browsers in pool"
675///     ).unwrap();
676/// }
677///
678/// fn update_metrics(pool: &Mutex<BrowserPool>) {
679///     if let Ok(stats) = get_pool_stats(pool) {
680///         POOL_AVAILABLE.set(stats.available as f64);
681///         POOL_ACTIVE.set(stats.active as f64);
682///     }
683/// }
684/// ```
685///
686/// ## Capacity Check
687///
688/// ```rust,ignore
689/// let stats = get_pool_stats(&pool)?;
690///
691/// if stats.available == 0 {
692///     log::warn!("No browsers available, requests may be delayed");
693/// }
694///
695/// let utilization = stats.active as f64 / stats.total.max(1) as f64;
696/// if utilization > 0.8 {
697///     log::warn!("Pool utilization at {:.0}%, consider scaling", utilization * 100.0);
698/// }
699/// ```
700pub fn get_pool_stats(pool: &Mutex<BrowserPool>) -> Result<PoolStatsResponse, PdfServiceError> {
701    let pool_guard = pool.lock().map_err(|e| {
702        log::error!("Failed to lock browser pool for stats: {}", e);
703        PdfServiceError::PoolLockFailed(e.to_string())
704    })?;
705
706    let stats = pool_guard.stats();
707
708    Ok(PoolStatsResponse {
709        available: stats.available,
710        active: stats.active,
711        total: stats.total,
712    })
713}
714
715/// Check if the browser pool is ready to handle requests.
716///
717/// Returns `true` if the pool has available browsers or capacity to create
718/// new ones. This is useful for readiness probes in container orchestration.
719///
720/// # Readiness Criteria
721///
722/// The pool is considered "ready" if either:
723/// - There are idle browsers available (`available > 0`), OR
724/// - There is capacity to create new browsers (`active < max_pool_size`)
725///
726/// The pool is "not ready" only when:
727/// - All browsers are in use AND the pool is at maximum capacity
728///
729/// # Arguments
730///
731/// * `pool` - Reference to the mutex-wrapped browser pool
732///
733/// # Returns
734///
735/// * `Ok(true)` - Pool can accept new requests
736/// * `Ok(false)` - Pool is at capacity, requests will queue
737/// * `Err(PdfServiceError::PoolLockFailed)` - If mutex is poisoned
738///
739/// # Use Cases
740///
741/// ## Kubernetes Readiness Probe
742///
743/// ```yaml
744/// readinessProbe:
745///   httpGet:
746///     path: /ready
747///     port: 8080
748///   initialDelaySeconds: 5
749///   periodSeconds: 10
750/// ```
751///
752/// ## Load Balancer Health Check
753///
754/// When `is_pool_ready` returns `false`, the endpoint should return
755/// HTTP 503 Service Unavailable to remove the instance from rotation.
756///
757/// # Examples
758///
759/// ## Basic Check
760///
761/// ```rust,ignore
762/// use html2pdf_api::service::is_pool_ready;
763///
764/// if is_pool_ready(&pool)? {
765///     println!("Pool is ready to accept requests");
766/// } else {
767///     println!("Pool is at capacity");
768/// }
769/// ```
770///
771/// ## Request Gating
772///
773/// ```rust,ignore
774/// async fn handle_request(pool: &Mutex<BrowserPool>, request: PdfFromUrlRequest) -> Result<PdfResponse, Error> {
775///     // Quick capacity check before expensive operation
776///     if !is_pool_ready(pool)? {
777///         return Err(Error::ServiceUnavailable("Pool at capacity, try again later"));
778///     }
779///     
780///     // Proceed with PDF generation
781///     generate_pdf_from_url(pool, &request)
782/// }
783/// ```
784pub fn is_pool_ready(pool: &Mutex<BrowserPool>) -> Result<bool, PdfServiceError> {
785    let pool_guard = pool.lock().map_err(|e| {
786        log::error!("Failed to lock browser pool for readiness check: {}", e);
787        PdfServiceError::PoolLockFailed(e.to_string())
788    })?;
789
790    let stats = pool_guard.stats();
791    let config = pool_guard.config();
792
793    // Ready if we have available browsers OR we can create more
794    let is_ready = stats.available > 0 || stats.active < config.max_pool_size;
795
796    log::trace!(
797        "Pool readiness check: available={}, active={}, max={}, ready={}",
798        stats.available,
799        stats.active,
800        config.max_pool_size,
801        is_ready
802    );
803
804    Ok(is_ready)
805}
806
807// ============================================================================
808// Internal Helper Functions
809// ============================================================================
810
811/// Validate and normalize a URL string.
812///
813/// Parses the URL using the `url` crate and returns the normalized form.
814/// This catches malformed URLs early, before acquiring a browser.
815///
816/// # Validation Rules
817///
818/// - URL must not be empty
819/// - URL must be parseable by the `url` crate
820/// - Scheme must be present (http/https/file/data)
821///
822/// # Arguments
823///
824/// * `url` - The URL string to validate
825///
826/// # Returns
827///
828/// * `Ok(String)` - The normalized URL
829/// * `Err(PdfServiceError::InvalidUrl)` - If validation fails
830///
831/// # Examples
832///
833/// ```rust,ignore
834/// assert!(validate_url("https://example.com").is_ok());
835/// assert!(validate_url("").is_err());
836/// assert!(validate_url("not-a-url").is_err());
837/// ```
838fn validate_url(url: &str) -> Result<String, PdfServiceError> {
839    // Check for empty URL first (better error message)
840    if url.trim().is_empty() {
841        log::debug!("URL validation failed: empty URL");
842        return Err(PdfServiceError::InvalidUrl("URL is required".to_string()));
843    }
844
845    // Parse and normalize the URL
846    match url::Url::parse(url) {
847        Ok(parsed) => {
848            log::trace!("URL validated successfully: {}", parsed);
849            Ok(parsed.to_string())
850        }
851        Err(e) => {
852            log::debug!("URL validation failed for '{}': {}", url, e);
853            Err(PdfServiceError::InvalidUrl(e.to_string()))
854        }
855    }
856}
857
858/// Acquire a browser from the pool.
859///
860/// Locks the pool mutex, retrieves a browser, and returns it. The lock is
861/// released immediately after checkout, not held during PDF generation.
862///
863/// # Browser Lifecycle
864///
865/// The returned `BrowserHandle` uses RAII to automatically return the
866/// browser to the pool when dropped:
867///
868/// ```text
869/// ┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
870/// │  acquire_browser │ ──▶ │  BrowserHandle  │ ──▶ │  PDF Generation │
871/// │  (lock, get)     │     │  (RAII guard)   │     │  (uses browser) │
872/// └─────────────────┘     └─────────────────┘     └────────┬────────┘
873///                                                          │
874///                                                          ▼
875///                         ┌─────────────────┐     ┌─────────────────┐
876///                         │  Back to Pool   │ ◀── │  Drop Handle    │
877///                         │  (automatic)    │     │  (RAII cleanup) │
878///                         └─────────────────┘     └─────────────────┘
879/// ```
880///
881/// # Arguments
882///
883/// * `pool` - Reference to the mutex-wrapped browser pool
884///
885/// # Returns
886///
887/// * `Ok(BrowserHandle)` - A browser ready for use
888/// * `Err(PdfServiceError)` - If pool lock or browser acquisition fails
889fn acquire_browser(pool: &Mutex<BrowserPool>) -> Result<BrowserHandle, PdfServiceError> {
890    // Acquire lock on the pool
891    let pool_guard = pool.lock().map_err(|e| {
892        log::error!("❌ Failed to lock browser pool: {}", e);
893        PdfServiceError::PoolLockFailed(e.to_string())
894    })?;
895
896    // Get a browser from the pool
897    let browser = pool_guard.get().map_err(|e| {
898        log::error!("❌ Failed to get browser from pool: {}", e);
899        PdfServiceError::BrowserUnavailable(e.to_string())
900    })?;
901
902    log::debug!("Acquired browser {} from pool", browser.id());
903
904    Ok(browser)
905    // pool_guard (MutexGuard) is dropped here, releasing the lock
906}
907
908/// Core PDF generation logic.
909///
910/// This function performs the actual work of:
911/// 1. Creating a new browser tab
912/// 2. Navigating to the URL
913/// 3. Waiting for JavaScript completion
914/// 4. Generating the PDF
915/// 5. Cleaning up the tab
916///
917/// # Arguments
918///
919/// * `browser` - Browser handle from the pool
920/// * `url` - URL to navigate to (can be http/https or data: URL)
921/// * `wait_duration` - How long to wait for JavaScript
922/// * `landscape` - Whether to use landscape orientation
923/// * `print_background` - Whether to include background graphics
924///
925/// # Returns
926///
927/// * `Ok(Vec<u8>)` - The raw PDF binary data
928/// * `Err(PdfServiceError)` - If any step fails
929///
930/// # Tab Lifecycle
931///
932/// A new tab is created for each PDF generation and closed afterward.
933/// This ensures clean state and prevents memory leaks from accumulating
934/// page resources.
935///
936/// ```text
937/// Browser Instance
938/// ├── Tab 1 (new) ◀── Created for this request
939/// │   ├── Navigate to URL
940/// │   ├── Wait for JS
941/// │   ├── Generate PDF
942/// │   └── Close tab ◀── Cleanup
943/// └── (available for next request)
944/// ```
945fn generate_pdf_internal(
946    browser: &BrowserHandle,
947    url: &str,
948    wait_duration: Duration,
949    landscape: bool,
950    print_background: bool,
951) -> Result<Vec<u8>, PdfServiceError> {
952    let start_time = Instant::now();
953
954    // Create new tab
955    log::trace!("Creating new browser tab");
956    let tab = browser.new_tab().map_err(|e| {
957        log::error!("❌ Failed to create tab: {}", e);
958        PdfServiceError::TabCreationFailed(e.to_string())
959    })?;
960
961    // Configure PDF options
962    let print_options = build_print_options(landscape, print_background);
963
964    // Navigate to URL
965    log::trace!("Navigating to URL: {}", truncate_url(url, 100));
966    let nav_start = Instant::now();
967
968    let page = tab
969        .navigate_to(url)
970        .map_err(|e| {
971            log::error!("❌ Failed to navigate to URL: {}", e);
972            PdfServiceError::NavigationFailed(e.to_string())
973        })?
974        .wait_until_navigated()
975        .map_err(|e| {
976            log::error!("❌ Navigation timeout: {}", e);
977            PdfServiceError::NavigationTimeout(e.to_string())
978        })?;
979
980    log::debug!("Navigation completed in {:?}", nav_start.elapsed());
981
982    // Wait for JavaScript execution
983    wait_for_page_ready(&tab, wait_duration);
984
985    // Generate PDF
986    log::trace!("Generating PDF");
987    let pdf_start = Instant::now();
988
989    let pdf_data = page.print_to_pdf(print_options).map_err(|e| {
990        log::error!("❌ Failed to generate PDF: {}", e);
991        PdfServiceError::PdfGenerationFailed(e.to_string())
992    })?;
993
994    log::debug!(
995        "PDF generated in {:?} ({} bytes)",
996        pdf_start.elapsed(),
997        pdf_data.len()
998    );
999
1000    // Close tab (best effort - don't fail if this doesn't work)
1001    close_tab_safely(&tab);
1002
1003    log::debug!("Total PDF generation time: {:?}", start_time.elapsed());
1004
1005    Ok(pdf_data)
1006}
1007
1008/// Build PDF print options.
1009///
1010/// Creates the `PrintToPdfOptions` struct with the specified settings
1011/// and sensible defaults for margins and other options.
1012///
1013/// # Default Settings
1014///
1015/// - **Margins**: All set to 0 (full page)
1016/// - **Header/Footer**: Disabled
1017/// - **Background**: Configurable (default: true)
1018/// - **Scale**: 1.0 (100%)
1019fn build_print_options(landscape: bool, print_background: bool) -> Option<PrintToPdfOptions> {
1020    Some(PrintToPdfOptions {
1021        landscape: Some(landscape),
1022        display_header_footer: Some(false),
1023        print_background: Some(print_background),
1024        // Zero margins for full-page output
1025        margin_top: Some(0.0),
1026        margin_bottom: Some(0.0),
1027        margin_left: Some(0.0),
1028        margin_right: Some(0.0),
1029        // Use defaults for everything else
1030        ..Default::default()
1031    })
1032}
1033
1034/// Wait for the page to signal it's ready for PDF generation.
1035///
1036/// This function implements a polling loop that checks for `window.isPageDone === true`.
1037/// This allows JavaScript-heavy pages to signal when they've finished rendering,
1038/// enabling early PDF generation without waiting the full timeout.
1039///
1040/// # Behavior Summary
1041///
1042/// | Page State | Result |
1043/// |------------|--------|
1044/// | `window.isPageDone = true` | Returns **immediately** (early exit) |
1045/// | `window.isPageDone = false` | Waits **full duration** |
1046/// | `window.isPageDone` not defined | Waits **full duration** |
1047/// | JavaScript error during check | Waits **full duration** |
1048///
1049/// # Default Behavior (No Flag Set)
1050///
1051/// **Important:** If the page does not set `window.isPageDone = true`, this function
1052/// waits the **full `max_wait` duration** before returning. This is intentional -
1053/// it gives JavaScript-heavy pages time to render even without explicit signaling.
1054///
1055/// For example, with the default `waitsecs = 5`:
1056/// - A page **with** the flag set immediately: ~0ms wait
1057/// - A page **without** the flag: full 5000ms wait
1058///
1059/// # How It Works
1060///
1061/// ```text
1062/// ┌─────────────────────────────────────────────────────────────────┐
1063/// │                    wait_for_page_ready                          │
1064/// │                                                                 │
1065/// │   ┌─────────┐     ┌──────────────┐     ┌─────────────────────┐  │
1066/// │   │  Start  │────▶│ Check flag   │────▶│ window.isPageDone?  │  │
1067/// │   └─────────┘     └──────────────┘     └──────────┬──────────┘  │
1068/// │                                                   │             │
1069/// │                          ┌────────────────────────┼─────────┐   │
1070/// │                          │                        │         │   │
1071/// │                          ▼                        ▼         │   │
1072/// │                   ┌────────────┐           ┌───────────┐    │   │
1073/// │                   │   true     │           │  false /  │    │   │
1074/// │                   │ (ready!)   │           │ undefined │    │   │
1075/// │                   └─────┬──────┘           └─────┬─────┘    │   │
1076/// │                         │                        │          │   │
1077/// │                         ▼                        ▼          │   │
1078/// │                   ┌───────────┐           ┌───────────┐     │   │
1079/// │                   │  Return   │           │ Sleep     │     │   │
1080/// │                   │  early    │           │ 200ms     │─────┘   │
1081/// │                   └───────────┘           └───────────┘         │
1082/// │                                                  │              │
1083/// │                                                  ▼              │
1084/// │                                           ┌───────────┐         │
1085/// │                                           │ Timeout?  │         │
1086/// │                                           └─────┬─────┘         │
1087/// │                                                 │               │
1088/// │                                    ┌────────────┴────────────┐  │
1089/// │                                    ▼                         ▼  │
1090/// │                             ┌───────────┐              ┌──────┐ │
1091/// │                             │   Yes     │              │  No  │ │
1092/// │                             │ (proceed) │              │(loop)│ │
1093/// │                             └───────────┘              └──────┘ │
1094/// └─────────────────────────────────────────────────────────────────┘
1095/// ```
1096///
1097/// # Polling Timeline
1098///
1099/// The function polls every 200ms (see `JS_POLL_INTERVAL_MS`):
1100///
1101/// ```text
1102/// Time:   0ms    200ms   400ms   600ms   800ms  ...  5000ms
1103///          │       │       │       │       │           │
1104///          ▼       ▼       ▼       ▼       ▼           ▼
1105///        Poll    Poll    Poll    Poll    Poll  ...   Timeout
1106///          │       │       │       │       │           │
1107///          └───────┴───────┴───────┴───────┴───────────┤
1108///                                                      ▼
1109///                                              Proceed to PDF
1110///
1111/// If window.isPageDone = true at any poll → Exit immediately
1112/// ```
1113///
1114/// Each poll executes this JavaScript:
1115///
1116/// ```javascript
1117/// window.isPageDone === true  // Returns true, false, or undefined
1118/// ```
1119///
1120/// - `true` → Function returns immediately
1121/// - `false` / `undefined` / error → Continue polling until timeout
1122///
1123/// # Page-Side Implementation (Optional)
1124///
1125/// To enable early completion and avoid unnecessary waiting, add this to your
1126/// page's JavaScript **after** all content is rendered:
1127///
1128/// ```javascript
1129/// // Signal that the page is ready for PDF generation
1130/// window.isPageDone = true;
1131/// ```
1132///
1133/// ## Framework Examples
1134///
1135/// **React:**
1136/// ```javascript
1137/// useEffect(() => {
1138///     fetchData().then((result) => {
1139///         setData(result);
1140///         // Signal ready after state update and re-render
1141///         setTimeout(() => { window.isPageDone = true; }, 0);
1142///     });
1143/// }, []);
1144/// ```
1145///
1146/// **Vue:**
1147/// ```javascript
1148/// mounted() {
1149///     this.loadData().then(() => {
1150///         this.$nextTick(() => {
1151///             window.isPageDone = true;
1152///         });
1153///     });
1154/// }
1155/// ```
1156///
1157/// **Vanilla JavaScript:**
1158/// ```javascript
1159/// document.addEventListener('DOMContentLoaded', async () => {
1160///     await loadDynamicContent();
1161///     await renderCharts();
1162///     window.isPageDone = true;  // All done!
1163/// });
1164/// ```
1165///
1166/// # When to Increase `waitsecs`
1167///
1168/// If you cannot modify the target page to set `window.isPageDone`, increase
1169/// `waitsecs` based on the page complexity:
1170///
1171/// | Page Type | Recommended `waitsecs` |
1172/// |-----------|------------------------|
1173/// | Static HTML (no JS) | 1 |
1174/// | Light JS (form validation, simple DOM) | 2-3 |
1175/// | Moderate JS (API calls, dynamic content) | 5 (default) |
1176/// | Heavy SPA (React, Vue, Angular) | 5-10 |
1177/// | Complex visualizations (D3, charts, maps) | 10-15 |
1178/// | Pages loading external resources | 10-20 |
1179///
1180/// # Performance Optimization
1181///
1182/// For high-throughput scenarios, implementing `window.isPageDone` on your
1183/// pages can significantly improve performance:
1184///
1185/// ```text
1186/// Without flag (5s default wait):
1187///     Request 1: ████████████████████ 5.2s
1188///     Request 2: ████████████████████ 5.1s
1189///     Request 3: ████████████████████ 5.3s
1190///     Average: 5.2s per PDF
1191///
1192/// With flag (page ready in 800ms):
1193///     Request 1: ████ 0.9s
1194///     Request 2: ████ 0.8s
1195///     Request 3: ████ 0.9s
1196///     Average: 0.87s per PDF (6x faster!)
1197/// ```
1198///
1199/// # Arguments
1200///
1201/// * `tab` - The browser tab to check. Must have completed navigation.
1202/// * `max_wait` - Maximum time to wait before proceeding with PDF generation.
1203///   This is the upper bound; the function may return earlier if the page
1204///   signals readiness.
1205///
1206/// # Returns
1207///
1208/// This function returns `()` (unit). It either:
1209/// - Returns early when `window.isPageDone === true` is detected
1210/// - Returns after `max_wait` duration has elapsed (timeout)
1211///
1212/// In both cases, PDF generation proceeds afterward. This function never fails -
1213/// timeout is a normal completion path, not an error.
1214///
1215/// # Thread Blocking
1216///
1217/// This function blocks the calling thread with `std::thread::sleep()`.
1218/// Always call from within a blocking context (e.g., `spawn_blocking`).
1219///
1220/// # Example
1221///
1222/// ```rust,ignore
1223/// // Navigate to page first
1224/// let page = tab.navigate_to(url)?.wait_until_navigated()?;
1225///
1226/// // Wait up to 10 seconds for JavaScript
1227/// wait_for_page_ready(&tab, Duration::from_secs(10));
1228///
1229/// // Now generate PDF - page is either ready or we've waited long enough
1230/// let pdf_data = page.print_to_pdf(options)?;
1231/// ```
1232fn wait_for_page_ready(tab: &headless_chrome::Tab, max_wait: Duration) {
1233    let start = Instant::now();
1234    let poll_interval = Duration::from_millis(JS_POLL_INTERVAL_MS);
1235
1236    log::trace!(
1237        "Waiting up to {:?} for page to be ready (polling every {:?})",
1238        max_wait,
1239        poll_interval
1240    );
1241
1242    while start.elapsed() < max_wait {
1243        // Check if page signals completion
1244        let is_done = tab
1245            .evaluate("window.isPageDone === true", false)
1246            .map(|result| {
1247                result
1248                    .value
1249                    .and_then(|v| v.as_bool())
1250                    .unwrap_or(false)
1251            })
1252            .unwrap_or(false);
1253
1254        if is_done {
1255            log::debug!(
1256                "Page signaled ready after {:?}",
1257                start.elapsed()
1258            );
1259            return;
1260        }
1261
1262        // Sleep before next poll
1263        std::thread::sleep(poll_interval);
1264    }
1265
1266    log::debug!(
1267        "Page wait completed after {:?} (timeout, proceeding anyway)",
1268        start.elapsed()
1269    );
1270}
1271
1272/// Safely close a browser tab, ignoring errors.
1273///
1274/// Tab cleanup is best-effort. If it fails, we log a warning but don't
1275/// propagate the error since the PDF generation already succeeded.
1276///
1277/// # Why Best-Effort?
1278///
1279/// - The PDF data is already captured
1280/// - Tab resources will be cleaned up when the browser is recycled
1281/// - Failing here would discard a valid PDF
1282/// - Some errors (e.g., browser already closed) are expected
1283///
1284/// # Arguments
1285///
1286/// * `tab` - The browser tab to close
1287fn close_tab_safely(tab: &headless_chrome::Tab) {
1288    log::trace!("Closing browser tab");
1289
1290    if let Err(e) = tab.close(true) {
1291        // Log but don't fail - PDF generation already succeeded
1292        log::warn!(
1293            "Failed to close tab (continuing anyway, resources will be cleaned up): {}",
1294            e
1295        );
1296    } else {
1297        log::trace!("Tab closed successfully");
1298    }
1299}
1300
1301/// Truncate a URL for logging purposes.
1302///
1303/// Data URLs can be extremely long (containing entire HTML documents).
1304/// This function truncates them for readable log output.
1305///
1306/// # Arguments
1307///
1308/// * `url` - The URL to truncate
1309/// * `max_len` - Maximum length before truncation
1310///
1311/// # Returns
1312///
1313/// The URL, truncated with "..." if longer than `max_len`.
1314fn truncate_url(url: &str, max_len: usize) -> String {
1315    if url.len() <= max_len {
1316        url.to_string()
1317    } else {
1318        format!("{}...", &url[..max_len])
1319    }
1320}
1321
1322// ============================================================================
1323// Unit Tests
1324// ============================================================================
1325
1326#[cfg(test)]
1327mod tests {
1328    use super::*;
1329
1330    // -------------------------------------------------------------------------
1331    // URL Validation Tests
1332    // -------------------------------------------------------------------------
1333
1334    #[test]
1335    fn test_validate_url_valid_https() {
1336        let result = validate_url("https://example.com");
1337        assert!(result.is_ok());
1338        assert_eq!(result.unwrap(), "https://example.com/");
1339    }
1340
1341    #[test]
1342    fn test_validate_url_valid_http() {
1343        let result = validate_url("http://example.com/path?query=value");
1344        assert!(result.is_ok());
1345    }
1346
1347    #[test]
1348    fn test_validate_url_valid_with_port() {
1349        let result = validate_url("http://localhost:3000/api");
1350        assert!(result.is_ok());
1351    }
1352
1353    #[test]
1354    fn test_validate_url_empty() {
1355        let result = validate_url("");
1356        assert!(matches!(result, Err(PdfServiceError::InvalidUrl(_))));
1357    }
1358
1359    #[test]
1360    fn test_validate_url_whitespace_only() {
1361        let result = validate_url("   ");
1362        assert!(matches!(result, Err(PdfServiceError::InvalidUrl(_))));
1363    }
1364
1365    #[test]
1366    fn test_validate_url_no_scheme() {
1367        let result = validate_url("example.com");
1368        assert!(matches!(result, Err(PdfServiceError::InvalidUrl(_))));
1369    }
1370
1371    #[test]
1372    fn test_validate_url_relative() {
1373        let result = validate_url("/path/to/page");
1374        assert!(matches!(result, Err(PdfServiceError::InvalidUrl(_))));
1375    }
1376
1377    #[test]
1378    fn test_validate_url_data_url() {
1379        let result = validate_url("data:text/html,<h1>Hello</h1>");
1380        assert!(result.is_ok());
1381    }
1382
1383    // -------------------------------------------------------------------------
1384    // Helper Function Tests
1385    // -------------------------------------------------------------------------
1386
1387    #[test]
1388    fn test_truncate_url_short() {
1389        let url = "https://example.com";
1390        assert_eq!(truncate_url(url, 50), url);
1391    }
1392
1393    #[test]
1394    fn test_truncate_url_long() {
1395        let url = "https://example.com/very/long/path/that/exceeds/the/maximum/length";
1396        let truncated = truncate_url(url, 30);
1397        assert_eq!(truncated.len(), 33); // 30 + "..."
1398        assert!(truncated.ends_with("..."));
1399    }
1400
1401    #[test]
1402    fn test_truncate_url_exact_length() {
1403        let url = "https://example.com";
1404        assert_eq!(truncate_url(url, url.len()), url);
1405    }
1406
1407    #[test]
1408    fn test_build_print_options_landscape() {
1409        let options = build_print_options(true, true).unwrap();
1410        assert_eq!(options.landscape, Some(true));
1411        assert_eq!(options.print_background, Some(true));
1412    }
1413
1414    #[test]
1415    fn test_build_print_options_portrait() {
1416        let options = build_print_options(false, false).unwrap();
1417        assert_eq!(options.landscape, Some(false));
1418        assert_eq!(options.print_background, Some(false));
1419    }
1420
1421    #[test]
1422    fn test_build_print_options_zero_margins() {
1423        let options = build_print_options(false, true).unwrap();
1424        assert_eq!(options.margin_top, Some(0.0));
1425        assert_eq!(options.margin_bottom, Some(0.0));
1426        assert_eq!(options.margin_left, Some(0.0));
1427        assert_eq!(options.margin_right, Some(0.0));
1428    }
1429
1430    #[test]
1431    fn test_build_print_options_no_header_footer() {
1432        let options = build_print_options(false, true).unwrap();
1433        assert_eq!(options.display_header_footer, Some(false));
1434    }
1435
1436    // -------------------------------------------------------------------------
1437    // Constants Tests
1438    // -------------------------------------------------------------------------
1439
1440    #[test]
1441    fn test_default_timeout_reasonable() {
1442        // Timeout should be at least 30 seconds for complex pages
1443        assert!(DEFAULT_TIMEOUT_SECS >= 30);
1444        // But not more than 5 minutes (would be too long)
1445        assert!(DEFAULT_TIMEOUT_SECS <= 300);
1446    }
1447
1448    #[test]
1449    fn test_default_wait_reasonable() {
1450        // Wait should be at least 1 second for any JS
1451        assert!(DEFAULT_WAIT_SECS >= 1);
1452        // But not more than 30 seconds by default
1453        assert!(DEFAULT_WAIT_SECS <= 30);
1454    }
1455
1456    #[test]
1457    fn test_poll_interval_reasonable() {
1458        // Poll interval should be at least 100ms (not too aggressive)
1459        assert!(JS_POLL_INTERVAL_MS >= 100);
1460        // But not more than 1 second (responsive enough)
1461        assert!(JS_POLL_INTERVAL_MS <= 1000);
1462    }
1463}