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    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_timeout(rx, env_timeout, "Environment creation")?
192            .map_err(|e| format!("Environment creation callback error: {}", e))?;
193
194        let (tx, rx) = mpsc::channel();
195        let controller_handler = CreateCoreWebView2ControllerCompletedHandler::create(Box::new(
196            move |error_code, controller| {
197                let result: Result<ICoreWebView2Controller, webview2_com::Error> = (|| {
198                    error_code?;
199                    controller.ok_or_else(|| {
200                        webview2_com::Error::WindowsError(windows::core::Error::from(E_POINTER))
201                    })
202                })(
203                );
204                tx.send(result)
205                    .map_err(|_| windows::core::Error::from(E_UNEXPECTED))
206            },
207        ));
208
209        environment
210            .CreateCoreWebView2Controller(hwnd, &controller_handler)
211            .map_err(|e| format!("CreateCoreWebView2Controller failed: {}", e))?;
212
213        let controller = wait_with_pump_timeout(rx, env_timeout, "Controller creation")?
214            .map_err(|e| format!("Controller creation callback error: {}", e))?;
215
216        let webview = controller
217            .CoreWebView2()
218            .map_err(|e| format!("Failed to get CoreWebView2: {}", e))?;
219
220        let settings = webview
221            .Settings()
222            .map_err(|e| format!("Failed to get settings: {}", e))?;
223        settings
224            .SetIsScriptEnabled(FALSE.into())
225            .map_err(|e| format!("Failed to disable JavaScript: {}", e))?;
226
227        settings
228            .SetAreDefaultScriptDialogsEnabled(FALSE.into())
229            .map_err(|e| format!("Failed to disable script dialogs: {}", e))?;
230
231        let perm_handler =
232            PermissionRequestedEventHandler::create(Box::new(move |_sender, args| {
233                if let Some(args) = args {
234                    let _ = args.SetState(COREWEBVIEW2_PERMISSION_STATE_DENY);
235                }
236                Ok(())
237            }));
238        let mut perm_token = 0i64;
239        webview
240            .add_PermissionRequested(&perm_handler, &mut perm_token)
241            .map_err(|e| format!("Failed to add PermissionRequested handler: {}", e))?;
242
243        let (tx, rx) = mpsc::channel();
244        let nav_handler =
245            NavigationCompletedEventHandler::create(Box::new(move |_sender, args| {
246                let mut success = windows::core::BOOL(0);
247                if let Some(ref a) = args {
248                    let _ = a.IsSuccess(&mut success);
249                }
250                let _ = tx.send(success.0 != 0);
251                Ok(())
252            }));
253        let mut nav_token = 0i64;
254        webview
255            .add_NavigationCompleted(&nav_handler, &mut nav_token)
256            .map_err(|e| format!("Failed to add NavigationCompleted handler: {}", e))?;
257
258        let url_wide: Vec<u16> = params
259            .url
260            .encode_utf16()
261            .chain(std::iter::once(0))
262            .collect();
263        webview
264            .Navigate(PCWSTR(url_wide.as_ptr()))
265            .map_err(|e| format!("Failed to navigate: {}", e))?;
266
267        let nav_success = wait_with_pump_timeout(rx, nav_timeout, "Navigation")?;
268        let _ = webview.remove_NavigationCompleted(nav_token);
269
270        if !nav_success {
271            return Err(format!("Navigation failed for URL: {}", params.url));
272        }
273
274        let env6 = environment
275            .cast::<ICoreWebView2Environment6>()
276            .map_err(|e| format!("Environment does not support PrintSettings: {}", e))?;
277
278        let print_settings = env6
279            .CreatePrintSettings()
280            .map_err(|e| format!("CreatePrintSettings failed: {}", e))?;
281
282        print_settings
283            .SetShouldPrintHeaderAndFooter(FALSE.into())
284            .map_err(|e| format!("SetShouldPrintHeaderAndFooter failed: {}", e))?;
285
286        print_settings
287            .SetOrientation(map_orientation(params.orientation.as_deref()))
288            .map_err(|e| format!("SetOrientation failed: {}", e))?;
289
290        if let Some(ref paper) = params.paper_size {
291            print_settings
292                .SetPageWidth(paper.width_inches)
293                .map_err(|e| format!("SetPageWidth failed: {}", e))?;
294            print_settings
295                .SetPageHeight(paper.height_inches)
296                .map_err(|e| format!("SetPageHeight failed: {}", e))?;
297        }
298
299        let webview7 = webview
300            .cast::<ICoreWebView2_7>()
301            .map_err(|e| format!("CoreWebView2 does not support ICoreWebView2_7: {}", e))?;
302
303        let output_path_str = params
304            .output_path
305            .to_str()
306            .ok_or_else(|| "Output path contains invalid UTF-8".to_string())?;
307        let output_path_wide: Vec<u16> = output_path_str
308            .encode_utf16()
309            .chain(std::iter::once(0))
310            .collect();
311
312        let (tx, rx) = mpsc::channel();
313        let pdf_handler =
314            PrintToPdfCompletedHandler::create(Box::new(move |error_code, success| {
315                let result: Result<bool, webview2_com::Error> = (|| {
316                    error_code?;
317                    Ok(success.into())
318                })();
319                tx.send(result)
320                    .map_err(|_| windows::core::Error::from(E_UNEXPECTED))
321            }));
322
323        webview7
324            .PrintToPdf(
325                PCWSTR(output_path_wide.as_ptr()),
326                &print_settings,
327                &pdf_handler,
328            )
329            .map_err(|e| format!("PrintToPdf call failed: {}", e))?;
330
331        let pdf_success = wait_with_pump_timeout(rx, pdf_timeout, "PrintToPdf")?
332            .map_err(|e| format!("PrintToPdf callback error: {}", e))?;
333
334        if !pdf_success {
335            return Err("PrintToPdf failed".to_string());
336        }
337
338        let _ = webview.remove_PermissionRequested(perm_token);
339
340        Ok(params.output_path)
341    }
342}