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    wait_with_pump, 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    unsafe {
116        let mut version = PWSTR::null();
117        let result =
118            GetAvailableCoreWebView2BrowserVersionString(PCWSTR::null(), &mut version);
119        let ok = result.is_ok() && !version.is_null();
120        if ok {
121            let _ = CoTaskMemFree(Some(version.0 as *const _));
122        }
123        ok
124    }
125}
126
127pub fn html_to_pdf(params: HtmlToPdfParams) -> Result<PathBuf, String> {
128    unsafe {
129        CoInitializeEx(None, COINIT_APARTMENTTHREADED)
130            .ok()
131            .map_err(|e| format!("CoInitializeEx failed: {}", e))?;
132    }
133    let result = (|| {
134        if !webview2_available() {
135            return Err(
136                "WebView2 Runtime is not installed. HTML printing requires WebView2 Runtime."
137                    .to_string(),
138            );
139        }
140        html_to_pdf_inner(params)
141    })();
142    unsafe {
143        CoUninitialize();
144    }
145    result
146}
147
148fn html_to_pdf_inner(params: HtmlToPdfParams) -> Result<PathBuf, String> {
149    let env_timeout = Duration::from_secs(10);
150    let nav_timeout = Duration::from_secs(15);
151    let pdf_timeout = Duration::from_secs(45);
152
153    unsafe {
154        let hwnd = create_hidden_window()?;
155        let _hwnd_guard = HwndGuard(hwnd);
156        let user_data_folder = params
157            .user_data_folder
158            .clone()
159            .unwrap_or_else(default_user_data_folder);
160        std::fs::create_dir_all(&user_data_folder)
161            .map_err(|e| format!("Failed to create WebView2 user data folder: {}", e))?;
162        let user_data_wide: Vec<u16> = user_data_folder
163            .to_str()
164            .ok_or_else(|| "WebView2 user data folder contains invalid UTF-8".to_string())?
165            .encode_utf16()
166            .chain(std::iter::once(0))
167            .collect();
168        let (tx, rx) = mpsc::channel();
169        let env_handler = CreateCoreWebView2EnvironmentCompletedHandler::create(Box::new(
170            move |error_code, env| {
171                let result: Result<ICoreWebView2Environment, webview2_com::Error> = (|| {
172                    error_code?;
173                    env.ok_or_else(|| {
174                        webview2_com::Error::WindowsError(windows::core::Error::from(E_POINTER))
175                    })
176                })(
177                );
178                tx.send(result)
179                    .map_err(|_| windows::core::Error::from(E_UNEXPECTED))
180            },
181        ));
182
183        CreateCoreWebView2EnvironmentWithOptions(
184            PCWSTR::null(),
185            PCWSTR(user_data_wide.as_ptr()),
186            None,
187            &env_handler,
188        )
189        .map_err(|e| format!("CreateCoreWebView2EnvironmentWithOptions failed: {}", e))?;
190
191        let environment = wait_with_pump(rx)
192            .map_err(|e| format!("wait_with_pump outer: {}", e))?
193            .map_err(|e| format!("wait_with_pump inner: {}", e))?;
194
195        let (tx, rx) = mpsc::channel();
196        let controller_handler = CreateCoreWebView2ControllerCompletedHandler::create(Box::new(
197            move |error_code, controller| {
198                let result: Result<ICoreWebView2Controller, webview2_com::Error> = (|| {
199                    error_code?;
200                    controller.ok_or_else(|| {
201                        webview2_com::Error::WindowsError(windows::core::Error::from(E_POINTER))
202                    })
203                })(
204                );
205                tx.send(result)
206                    .map_err(|_| windows::core::Error::from(E_UNEXPECTED))
207            },
208        ));
209
210        environment
211            .CreateCoreWebView2Controller(hwnd, &controller_handler)
212            .map_err(|e| format!("CreateCoreWebView2Controller failed: {}", e))?;
213
214        let controller = wait_with_pump_timeout(rx, env_timeout, "Controller creation")?
215            .map_err(|e| format!("Controller creation callback error: {}", e))?;
216
217        let webview = controller
218            .CoreWebView2()
219            .map_err(|e| format!("Failed to get CoreWebView2: {}", e))?;
220
221        let settings = webview
222            .Settings()
223            .map_err(|e| format!("Failed to get settings: {}", e))?;
224        settings
225            .SetIsScriptEnabled(FALSE.into())
226            .map_err(|e| format!("Failed to disable JavaScript: {}", e))?;
227
228        settings
229            .SetAreDefaultScriptDialogsEnabled(FALSE.into())
230            .map_err(|e| format!("Failed to disable script dialogs: {}", e))?;
231
232        let perm_handler =
233            PermissionRequestedEventHandler::create(Box::new(move |_sender, args| {
234                if let Some(args) = args {
235                    let _ = args.SetState(COREWEBVIEW2_PERMISSION_STATE_DENY);
236                }
237                Ok(())
238            }));
239        let mut perm_token = 0i64;
240        webview
241            .add_PermissionRequested(&perm_handler, &mut perm_token)
242            .map_err(|e| format!("Failed to add PermissionRequested handler: {}", e))?;
243
244        let (tx, rx) = mpsc::channel();
245        let nav_handler =
246            NavigationCompletedEventHandler::create(Box::new(move |_sender, args| {
247                let mut success = windows::core::BOOL(0);
248                if let Some(ref a) = args {
249                    let _ = a.IsSuccess(&mut success);
250                }
251                let _ = tx.send(success.0 != 0);
252                Ok(())
253            }));
254        let mut nav_token = 0i64;
255        webview
256            .add_NavigationCompleted(&nav_handler, &mut nav_token)
257            .map_err(|e| format!("Failed to add NavigationCompleted handler: {}", e))?;
258
259        let url_wide: Vec<u16> = params
260            .url
261            .encode_utf16()
262            .chain(std::iter::once(0))
263            .collect();
264        webview
265            .Navigate(PCWSTR(url_wide.as_ptr()))
266            .map_err(|e| format!("Failed to navigate: {}", e))?;
267
268        let nav_success = wait_with_pump_timeout(rx, nav_timeout, "Navigation")?;
269        let _ = webview.remove_NavigationCompleted(nav_token);
270
271        if !nav_success {
272            return Err(format!("Navigation failed for URL: {}", params.url));
273        }
274
275        let env6 = environment
276            .cast::<ICoreWebView2Environment6>()
277            .map_err(|e| format!("Environment does not support PrintSettings: {}", e))?;
278
279        let print_settings = env6
280            .CreatePrintSettings()
281            .map_err(|e| format!("CreatePrintSettings failed: {}", e))?;
282
283        print_settings
284            .SetShouldPrintHeaderAndFooter(FALSE.into())
285            .map_err(|e| format!("SetShouldPrintHeaderAndFooter failed: {}", e))?;
286
287        print_settings
288            .SetOrientation(map_orientation(params.orientation.as_deref()))
289            .map_err(|e| format!("SetOrientation failed: {}", e))?;
290
291        if let Some(ref paper) = params.paper_size {
292            print_settings
293                .SetPageWidth(paper.width_inches)
294                .map_err(|e| format!("SetPageWidth failed: {}", e))?;
295            print_settings
296                .SetPageHeight(paper.height_inches)
297                .map_err(|e| format!("SetPageHeight failed: {}", e))?;
298        }
299
300        let webview7 = webview
301            .cast::<ICoreWebView2_7>()
302            .map_err(|e| format!("CoreWebView2 does not support ICoreWebView2_7: {}", e))?;
303
304        let output_path_str = params
305            .output_path
306            .to_str()
307            .ok_or_else(|| "Output path contains invalid UTF-8".to_string())?;
308        let output_path_wide: Vec<u16> = output_path_str
309            .encode_utf16()
310            .chain(std::iter::once(0))
311            .collect();
312
313        let (tx, rx) = mpsc::channel();
314        let pdf_handler =
315            PrintToPdfCompletedHandler::create(Box::new(move |error_code, success| {
316                let result: Result<bool, webview2_com::Error> = (|| {
317                    error_code?;
318                    Ok(success.into())
319                })();
320                tx.send(result)
321                    .map_err(|_| windows::core::Error::from(E_UNEXPECTED))
322            }));
323
324        webview7
325            .PrintToPdf(
326                PCWSTR(output_path_wide.as_ptr()),
327                &print_settings,
328                &pdf_handler,
329            )
330            .map_err(|e| format!("PrintToPdf call failed: {}", e))?;
331
332        let pdf_success = wait_with_pump_timeout(rx, pdf_timeout, "PrintToPdf")?
333            .map_err(|e| format!("PrintToPdf callback error: {}", e))?;
334
335        if !pdf_success {
336            return Err("PrintToPdf failed".to_string());
337        }
338
339        let _ = webview.remove_PermissionRequested(perm_token);
340
341        Ok(params.output_path)
342    }
343}