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 #[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}