1use 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}