Skip to main content

winprint_kit/
html.rs

1//! HTML → PDF printing (WebView2).
2
3use std::path::PathBuf;
4use std::sync::mpsc;
5use std::time::{Duration, Instant};
6use webview2_com::{
7    CreateCoreWebView2ControllerCompletedHandler,
8    CreateCoreWebView2EnvironmentCompletedHandler, NavigationCompletedEventHandler,
9    PermissionRequestedEventHandler, PrintToPdfCompletedHandler,
10};
11use windows::core::{Interface, PCWSTR, PWSTR};
12use windows::Win32::Foundation::{E_POINTER, E_UNEXPECTED, FALSE, HWND};
13use windows::Win32::System::Com::{
14    CoInitializeEx, CoTaskMemFree, CoUninitialize, COINIT_APARTMENTTHREADED,
15};
16use windows::Win32::UI::WindowsAndMessaging::*;
17
18use webview2_com::Microsoft::Web::WebView2::Win32::*;
19
20#[derive(Debug, Clone)]
21pub struct PageSizeInfo {
22    pub display_name: String,
23    pub width_inches: f64,
24    pub height_inches: f64,
25}
26
27pub struct HtmlToPdfParams {
28    pub url: String,
29    pub output_path: PathBuf,
30    pub paper_size: Option<PageSizeInfo>,
31    pub orientation: Option<String>,
32    pub user_data_folder: Option<PathBuf>,
33}
34
35fn map_orientation(input: Option<&str>) -> COREWEBVIEW2_PRINT_ORIENTATION {
36    match input.map(|s| s.trim().to_lowercase()).as_deref() {
37        Some("landscape") => COREWEBVIEW2_PRINT_ORIENTATION_LANDSCAPE,
38        _ => COREWEBVIEW2_PRINT_ORIENTATION_PORTRAIT,
39    }
40}
41
42struct HwndGuard(HWND);
43
44impl Drop for HwndGuard {
45    fn drop(&mut self) {
46        if !self.0.is_invalid() {
47            unsafe {
48                let _ = DestroyWindow(self.0);
49            }
50        }
51    }
52}
53
54fn wait_with_pump_timeout<T>(
55    rx: mpsc::Receiver<T>,
56    timeout: Duration,
57    context: &str,
58) -> Result<T, String> {
59    let deadline = Instant::now() + timeout;
60    loop {
61        match rx.recv_timeout(Duration::from_millis(100)) {
62            Ok(val) => return Ok(val),
63            Err(mpsc::RecvTimeoutError::Timeout) => {
64                if Instant::now() >= deadline {
65                    return Err(format!(
66                        "{}: timed out after {}s",
67                        context,
68                        timeout.as_secs()
69                    ));
70                }
71                unsafe {
72                    let mut msg = std::mem::zeroed();
73                    while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).into() {
74                        let _ = TranslateMessage(&msg);
75                        DispatchMessageW(&msg);
76                    }
77                }
78            }
79            Err(mpsc::RecvTimeoutError::Disconnected) => {
80                return Err(format!("{}: channel disconnected", context));
81            }
82        }
83    }
84}
85
86unsafe fn create_hidden_window() -> Result<HWND, String> {
87    let class_name = windows::core::w!("Static");
88    let hwnd = CreateWindowExW(
89        WINDOW_EX_STYLE(0),
90        class_name,
91        windows::core::w!(""),
92        WINDOW_STYLE(0),
93        0,
94        0,
95        0,
96        0,
97        Some(HWND_MESSAGE),
98        None,
99        None,
100        None,
101    )
102    .map_err(|e| format!("CreateWindowExW failed: {}", e))?;
103    Ok(hwnd)
104}
105
106fn default_user_data_folder() -> PathBuf {
107    std::env::var_os("LOCALAPPDATA")
108        .map(PathBuf::from)
109        .unwrap_or_else(std::env::temp_dir)
110        .join("winprint-kit")
111        .join("webview2_print")
112}
113
114pub fn webview2_available() -> bool {
115    #[cfg(debug_assertions)]
116    if std::env::var_os("WINPRINT_SIM_NO_WEBVIEW2").is_some() {
117        return false;
118    }
119    unsafe {
120        let mut version = PWSTR::null();
121        let result =
122            GetAvailableCoreWebView2BrowserVersionString(PCWSTR::null(), &mut version);
123        let ok = result.is_ok() && !version.is_null();
124        if ok {
125            let _ = CoTaskMemFree(Some(version.0 as *const _));
126        }
127        ok
128    }
129}
130
131pub fn html_to_pdf(params: HtmlToPdfParams) -> Result<PathBuf, String> {
132    unsafe {
133        CoInitializeEx(None, COINIT_APARTMENTTHREADED)
134            .ok()
135            .map_err(|e| format!("CoInitializeEx failed: {}", e))?;
136    }
137    let result = (|| {
138        if !webview2_available() {
139            return Err(
140                "WebView2 Runtime is not installed. HTML printing requires WebView2 Runtime."
141                    .to_string(),
142            );
143        }
144        html_to_pdf_inner(params)
145    })();
146    unsafe {
147        CoUninitialize();
148    }
149    result
150}
151
152fn html_to_pdf_inner(params: HtmlToPdfParams) -> Result<PathBuf, String> {
153    let env_timeout = Duration::from_secs(10);
154    let nav_timeout = Duration::from_secs(15);
155    let pdf_timeout = Duration::from_secs(45);
156
157    unsafe {
158        let hwnd = create_hidden_window()?;
159        let _hwnd_guard = HwndGuard(hwnd);
160        let user_data_folder = params
161            .user_data_folder
162            .clone()
163            .unwrap_or_else(default_user_data_folder);
164        std::fs::create_dir_all(&user_data_folder)
165            .map_err(|e| format!("Failed to create WebView2 user data folder: {}", e))?;
166        let user_data_wide: Vec<u16> = user_data_folder
167            .to_str()
168            .ok_or_else(|| "WebView2 user data folder contains invalid UTF-8".to_string())?
169            .encode_utf16()
170            .chain(std::iter::once(0))
171            .collect();
172        let (tx, rx) = mpsc::channel();
173        let env_handler = CreateCoreWebView2EnvironmentCompletedHandler::create(Box::new(
174            move |error_code, env| {
175                let result: Result<ICoreWebView2Environment, webview2_com::Error> = (|| {
176                    error_code?;
177                    env.ok_or_else(|| {
178                        webview2_com::Error::WindowsError(windows::core::Error::from(E_POINTER))
179                    })
180                })(
181                );
182                tx.send(result)
183                    .map_err(|_| windows::core::Error::from(E_UNEXPECTED))
184            },
185        ));
186
187        CreateCoreWebView2EnvironmentWithOptions(
188            PCWSTR::null(),
189            PCWSTR(user_data_wide.as_ptr()),
190            None,
191            &env_handler,
192        )
193        .map_err(|e| format!("CreateCoreWebView2EnvironmentWithOptions failed: {}", e))?;
194
195        let environment = wait_with_pump_timeout(rx, env_timeout, "Environment creation")?
196            .map_err(|e| format!("Environment creation callback error: {}", e))?;
197
198        let (tx, rx) = mpsc::channel();
199        let controller_handler = CreateCoreWebView2ControllerCompletedHandler::create(Box::new(
200            move |error_code, controller| {
201                let result: Result<ICoreWebView2Controller, webview2_com::Error> = (|| {
202                    error_code?;
203                    controller.ok_or_else(|| {
204                        webview2_com::Error::WindowsError(windows::core::Error::from(E_POINTER))
205                    })
206                })(
207                );
208                tx.send(result)
209                    .map_err(|_| windows::core::Error::from(E_UNEXPECTED))
210            },
211        ));
212
213        environment
214            .CreateCoreWebView2Controller(hwnd, &controller_handler)
215            .map_err(|e| format!("CreateCoreWebView2Controller failed: {}", e))?;
216
217        let controller = wait_with_pump_timeout(rx, env_timeout, "Controller creation")?
218            .map_err(|e| format!("Controller creation callback error: {}", e))?;
219
220        let webview = controller
221            .CoreWebView2()
222            .map_err(|e| format!("Failed to get CoreWebView2: {}", e))?;
223
224        let settings = webview
225            .Settings()
226            .map_err(|e| format!("Failed to get settings: {}", e))?;
227        settings
228            .SetIsScriptEnabled(FALSE.into())
229            .map_err(|e| format!("Failed to disable JavaScript: {}", e))?;
230
231        settings
232            .SetAreDefaultScriptDialogsEnabled(FALSE.into())
233            .map_err(|e| format!("Failed to disable script dialogs: {}", e))?;
234
235        let perm_handler =
236            PermissionRequestedEventHandler::create(Box::new(move |_sender, args| {
237                if let Some(args) = args {
238                    let _ = args.SetState(COREWEBVIEW2_PERMISSION_STATE_DENY);
239                }
240                Ok(())
241            }));
242        let mut perm_token = 0i64;
243        webview
244            .add_PermissionRequested(&perm_handler, &mut perm_token)
245            .map_err(|e| format!("Failed to add PermissionRequested handler: {}", e))?;
246
247        let (tx, rx) = mpsc::channel();
248        let nav_handler =
249            NavigationCompletedEventHandler::create(Box::new(move |_sender, args| {
250                let mut success = windows::core::BOOL(0);
251                if let Some(ref a) = args {
252                    let _ = a.IsSuccess(&mut success);
253                }
254                let _ = tx.send(success.0 != 0);
255                Ok(())
256            }));
257        let mut nav_token = 0i64;
258        webview
259            .add_NavigationCompleted(&nav_handler, &mut nav_token)
260            .map_err(|e| format!("Failed to add NavigationCompleted handler: {}", e))?;
261
262        let url_wide: Vec<u16> = params
263            .url
264            .encode_utf16()
265            .chain(std::iter::once(0))
266            .collect();
267        webview
268            .Navigate(PCWSTR(url_wide.as_ptr()))
269            .map_err(|e| format!("Failed to navigate: {}", e))?;
270
271        let nav_success = wait_with_pump_timeout(rx, nav_timeout, "Navigation")?;
272        let _ = webview.remove_NavigationCompleted(nav_token);
273
274        if !nav_success {
275            return Err(format!("Navigation failed for URL: {}", params.url));
276        }
277
278        let env6 = environment
279            .cast::<ICoreWebView2Environment6>()
280            .map_err(|e| format!("Environment does not support PrintSettings: {}", e))?;
281
282        let print_settings = env6
283            .CreatePrintSettings()
284            .map_err(|e| format!("CreatePrintSettings failed: {}", e))?;
285
286        print_settings
287            .SetShouldPrintHeaderAndFooter(FALSE.into())
288            .map_err(|e| format!("SetShouldPrintHeaderAndFooter failed: {}", e))?;
289
290        print_settings
291            .SetOrientation(map_orientation(params.orientation.as_deref()))
292            .map_err(|e| format!("SetOrientation failed: {}", e))?;
293
294        if let Some(ref paper) = params.paper_size {
295            print_settings
296                .SetPageWidth(paper.width_inches)
297                .map_err(|e| format!("SetPageWidth failed: {}", e))?;
298            print_settings
299                .SetPageHeight(paper.height_inches)
300                .map_err(|e| format!("SetPageHeight failed: {}", e))?;
301        }
302
303        let webview7 = webview
304            .cast::<ICoreWebView2_7>()
305            .map_err(|e| format!("CoreWebView2 does not support ICoreWebView2_7: {}", e))?;
306
307        let output_path_str = params
308            .output_path
309            .to_str()
310            .ok_or_else(|| "Output path contains invalid UTF-8".to_string())?;
311        let output_path_wide: Vec<u16> = output_path_str
312            .encode_utf16()
313            .chain(std::iter::once(0))
314            .collect();
315
316        let (tx, rx) = mpsc::channel();
317        let pdf_handler =
318            PrintToPdfCompletedHandler::create(Box::new(move |error_code, success| {
319                let result: Result<bool, webview2_com::Error> = (|| {
320                    error_code?;
321                    Ok(success.into())
322                })();
323                tx.send(result)
324                    .map_err(|_| windows::core::Error::from(E_UNEXPECTED))
325            }));
326
327        webview7
328            .PrintToPdf(
329                PCWSTR(output_path_wide.as_ptr()),
330                &print_settings,
331                &pdf_handler,
332            )
333            .map_err(|e| format!("PrintToPdf call failed: {}", e))?;
334
335        let pdf_success = wait_with_pump_timeout(rx, pdf_timeout, "PrintToPdf")?
336            .map_err(|e| format!("PrintToPdf callback error: {}", e))?;
337
338        if !pdf_success {
339            return Err("PrintToPdf failed".to_string());
340        }
341
342        let _ = webview.remove_PermissionRequested(perm_token);
343
344        Ok(params.output_path)
345    }
346}