1use crate::dom::interactive::InteractiveElement;
2use crate::dom::SearchResults;
3use anyhow::Result;
4use base64::Engine;
5use scraper::{Html, Selector};
6use serde::{Deserialize, Serialize};
7use std::path::PathBuf;
8
9use std::sync::atomic::{AtomicUsize, Ordering};
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct ScreenshotResult {
13 pub width: u32,
14 pub height: u32,
15 pub svg: String,
16 pub layout_wireframe: String,
17 pub element_count: usize,
18 #[serde(skip_serializing_if = "Vec::is_empty")]
19 pub png_bytes: Vec<u8>,
20 pub png_base64: String,
21}
22
23pub struct RealBrowserScreenshot;
24
25struct TempFileCleanup(PathBuf);
26impl Drop for TempFileCleanup {
27 fn drop(&mut self) {
28 let _ = std::fs::remove_file(&self.0);
29 }
30}
31
32impl RealBrowserScreenshot {
33 pub fn find_browser_binary() -> Option<PathBuf> {
34 #[cfg(target_os = "windows")]
35 {
36 let candidates = [
37 r"C:\Program Files\Google\Chrome\Application\chrome.exe",
38 r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
39 r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
40 r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
41 ];
42 for path_str in &candidates {
43 let p = std::path::Path::new(path_str);
44 if p.exists() {
45 return Some(p.to_path_buf());
46 }
47 }
48 if let Ok(local_app_data) = std::env::var("LOCALAPPDATA") {
49 let chrome_local = std::path::Path::new(&local_app_data)
50 .join(r"Google\Chrome\Application\chrome.exe");
51 if chrome_local.exists() {
52 return Some(chrome_local);
53 }
54 let edge_local = std::path::Path::new(&local_app_data)
55 .join(r"Microsoft\Edge\Application\msedge.exe");
56 if edge_local.exists() {
57 return Some(edge_local);
58 }
59 }
60 if let Ok(output) = std::process::Command::new("where").arg("chrome").output() {
62 if output.status.success() {
63 let path_str = String::from_utf8_lossy(&output.stdout)
64 .lines()
65 .next()
66 .unwrap_or("")
67 .trim()
68 .to_string();
69 if !path_str.is_empty() {
70 return Some(PathBuf::from(path_str));
71 }
72 }
73 }
74 if let Ok(output) = std::process::Command::new("where").arg("msedge").output() {
75 if output.status.success() {
76 let path_str = String::from_utf8_lossy(&output.stdout)
77 .lines()
78 .next()
79 .unwrap_or("")
80 .trim()
81 .to_string();
82 if !path_str.is_empty() {
83 return Some(PathBuf::from(path_str));
84 }
85 }
86 }
87 }
88
89 #[cfg(target_os = "macos")]
90 {
91 let candidates = [
92 "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
93 "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
94 "/Applications/Chromium.app/Contents/MacOS/Chromium",
95 ];
96 for path_str in &candidates {
97 let p = std::path::Path::new(path_str);
98 if p.exists() {
99 return Some(p.to_path_buf());
100 }
101 }
102 if let Ok(output) = std::process::Command::new("which")
104 .arg("google-chrome")
105 .output()
106 {
107 if output.status.success() {
108 let path_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
109 if !path_str.is_empty() {
110 return Some(PathBuf::from(path_str));
111 }
112 }
113 }
114 }
115
116 #[cfg(target_os = "linux")]
117 {
118 let candidates = [
119 "google-chrome",
120 "google-chrome-stable",
121 "chromium",
122 "chromium-browser",
123 ];
124 for bin in &candidates {
125 if let Ok(output) = std::process::Command::new("which").arg(bin).output() {
126 if output.status.success() {
127 let path_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
128 if !path_str.is_empty() {
129 return Some(PathBuf::from(path_str));
130 }
131 }
132 }
133 }
134 }
135
136 None
137 }
138
139 fn generate_temp_png_path() -> PathBuf {
140 let temp_dir = std::env::temp_dir();
141 let pid = std::process::id();
142 let time = std::time::SystemTime::now()
143 .duration_since(std::time::UNIX_EPOCH)
144 .map(|d| d.as_millis())
145 .unwrap_or(0);
146 static COUNTER: AtomicUsize = AtomicUsize::new(0);
147 let count = COUNTER.fetch_add(1, Ordering::Relaxed);
148 temp_dir.join(format!("headless_shot_{}_{}_{}.png", pid, time, count))
149 }
150
151 fn is_valid_url(url: &str) -> bool {
152 url.starts_with("http://") || url.starts_with("https://") || url.starts_with("file://")
153 }
154
155 pub async fn capture_real_screenshot_async(
156 url: &str,
157 html_str: &str,
158 width: u32,
159 height: u32,
160 ) -> Option<ScreenshotResult> {
161 if !Self::is_valid_url(url) {
162 return None;
163 }
164
165 let browser_bin = Self::find_browser_binary()?;
166
167 let temp_png = Self::generate_temp_png_path();
168 let mut temp_html = temp_png.clone();
169 temp_html.set_extension("html");
170
171 let mut injected_html = html_str.to_string();
172 let base_tag = format!("<base href=\"{}\">", url);
173 if let Some(idx) = injected_html.find("<head>") {
174 injected_html.insert_str(idx + 6, &base_tag);
175 } else if let Some(idx) = injected_html.find("<head ") {
176 if let Some(close_idx) = injected_html[idx..].find('>') {
177 injected_html.insert_str(idx + close_idx + 1, &base_tag);
178 }
179 } else {
180 injected_html.insert_str(0, &base_tag);
181 }
182
183 let _ = std::fs::write(&temp_html, injected_html);
184
185 let _cleanup_png = TempFileCleanup(temp_png.clone());
186 let _cleanup_html = TempFileCleanup(temp_html.clone());
187
188 let temp_png_str = temp_png.to_string_lossy().to_string();
189 let temp_html_str = format!(
190 "file:///{}",
191 temp_html.to_string_lossy().to_string().replace('\\', "/")
192 );
193
194 let screenshot_arg = format!("--screenshot={}", temp_png_str);
195 let window_size_arg = format!("--window-size={},{}", width, height);
196
197 let mut cmd = tokio::process::Command::new(&browser_bin);
198 cmd.arg("--headless=new")
199 .arg("--disable-gpu")
200 .arg("--no-sandbox")
201 .arg("--hide-scrollbars")
202 .arg("--disable-blink-features=AutomationControlled")
203 .arg("--virtual-time-budget=8000")
204 .arg(&window_size_arg)
205 .arg(&screenshot_arg)
206 .arg(&temp_html_str);
207
208 #[cfg(target_os = "windows")]
209 cmd.creation_flags(0x08000000); if let Ok(child) = cmd.spawn() {
212 let _ =
213 tokio::time::timeout(std::time::Duration::from_secs(12), child.wait_with_output())
214 .await;
215 }
216
217 if temp_png.exists() {
218 if let Ok(png_bytes) = std::fs::read(&temp_png) {
219 let png_base64 = format!(
220 "data:image/png;base64,{}",
221 base64::engine::general_purpose::STANDARD.encode(&png_bytes)
222 );
223 return Some(ScreenshotResult {
224 width,
225 height,
226 svg: String::new(),
227 layout_wireframe: String::new(),
228 element_count: 1,
229 png_bytes,
230 png_base64,
231 });
232 }
233 }
234 None
235 }
236
237 pub fn capture_real_screenshot_sync(
238 url: &str,
239 html_str: &str,
240 width: u32,
241 height: u32,
242 ) -> Option<ScreenshotResult> {
243 if !Self::is_valid_url(url) {
244 return None;
245 }
246
247 let browser_bin = Self::find_browser_binary()?;
248
249 let temp_png = Self::generate_temp_png_path();
250 let mut temp_html = temp_png.clone();
251 temp_html.set_extension("html");
252
253 let mut injected_html = html_str.to_string();
254 let base_tag = format!("<base href=\"{}\">", url);
255 if let Some(idx) = injected_html.find("<head>") {
256 injected_html.insert_str(idx + 6, &base_tag);
257 } else if let Some(idx) = injected_html.find("<head ") {
258 if let Some(close_idx) = injected_html[idx..].find('>') {
259 injected_html.insert_str(idx + close_idx + 1, &base_tag);
260 }
261 } else {
262 injected_html.insert_str(0, &base_tag);
263 }
264
265 let _ = std::fs::write(&temp_html, injected_html);
266
267 let _cleanup_png = TempFileCleanup(temp_png.clone());
268 let _cleanup_html = TempFileCleanup(temp_html.clone());
269
270 let temp_png_str = temp_png.to_string_lossy().to_string();
271 let temp_html_str = format!(
272 "file:///{}",
273 temp_html.to_string_lossy().to_string().replace('\\', "/")
274 );
275
276 let screenshot_arg = format!("--screenshot={}", temp_png_str);
277 let window_size_arg = format!("--window-size={},{}", width, height);
278
279 let mut cmd = std::process::Command::new(&browser_bin);
280 cmd.arg("--headless=new")
281 .arg("--disable-gpu")
282 .arg("--no-sandbox")
283 .arg("--hide-scrollbars")
284 .arg("--disable-blink-features=AutomationControlled")
285 .arg("--virtual-time-budget=8000")
286 .arg(&window_size_arg)
287 .arg(&screenshot_arg)
288 .arg(&temp_html_str);
289
290 #[cfg(target_os = "windows")]
291 {
292 use std::os::windows::process::CommandExt;
293 cmd.creation_flags(0x08000000); }
295
296 if let Ok(mut child) = cmd.spawn() {
297 let start = std::time::Instant::now();
298 let timeout = std::time::Duration::from_secs(12);
299 loop {
300 if let Ok(Some(_)) = child.try_wait() {
301 break;
302 }
303 if start.elapsed() > timeout {
304 let _ = child.kill();
305 let _ = child.wait();
306 break;
307 }
308 std::thread::sleep(std::time::Duration::from_millis(50));
309 }
310 }
311
312 if temp_png.exists() {
313 if let Ok(png_bytes) = std::fs::read(&temp_png) {
314 let png_base64 = format!(
315 "data:image/png;base64,{}",
316 base64::engine::general_purpose::STANDARD.encode(&png_bytes)
317 );
318 return Some(ScreenshotResult {
319 width,
320 height,
321 svg: String::new(),
322 layout_wireframe: String::new(),
323 element_count: 1,
324 png_bytes,
325 png_base64,
326 });
327 }
328 }
329 None
330 }
331}
332
333pub struct PageRenderer;
334
335impl PageRenderer {
336 pub async fn render_async(
337 url: &str,
338 title: &str,
339 html_str: &str,
340 interactive: &[InteractiveElement],
341 _search_results: Option<&SearchResults>,
342 ) -> ScreenshotResult {
343 let width = 1280;
344 let height = 720;
345
346 if let Some(real_shot) =
348 RealBrowserScreenshot::capture_real_screenshot_async(url, html_str, width, height).await
349 {
350 return real_shot;
351 }
352
353 crate::render::HtmlRenderer::render_html_to_screenshot(
355 url,
356 title,
357 html_str,
358 interactive,
359 width,
360 height,
361 )
362 .await
363 .unwrap_or_else(|_| {
364 Self::render_general_page(url, title, html_str, interactive, width, height)
365 })
366 }
367
368 pub fn render(
369 url: &str,
370 title: &str,
371 html_str: &str,
372 interactive: &[InteractiveElement],
373 _search_results: Option<&SearchResults>,
374 ) -> ScreenshotResult {
375 let width = 1280;
376 let height = 720;
377
378 if let Some(real_shot) =
379 RealBrowserScreenshot::capture_real_screenshot_sync(url, html_str, width, height)
380 {
381 return real_shot;
382 }
383
384 Self::render_general_page(url, title, html_str, interactive, width, height)
385 }
386
387 fn render_general_page(
388 url: &str,
389 title: &str,
390 html_str: &str,
391 interactive: &[InteractiveElement],
392 width: u32,
393 height: u32,
394 ) -> ScreenshotResult {
395 let mut y_offset = 120;
396 let document = Html::parse_document(html_str);
397
398 let mut visual_blocks = Vec::new();
399 if let Ok(sel) = Selector::parse("h1, h2, h3, p, button, a[href], input, li") {
400 for el in document.select(&sel) {
401 let tag = el.value().name();
402 let text = el.text().collect::<Vec<_>>().join(" ").trim().to_string();
403 if text.is_empty() || text.len() < 2 {
404 continue;
405 }
406
407 let linked_index = interactive.iter().find(|i| i.text == text).map(|i| i.index);
408 visual_blocks.push((tag.to_string(), text, linked_index));
409 if visual_blocks.len() >= 50 {
410 break;
411 }
412 }
413 }
414
415 let title_escaped = Self::xml_escape(&title.chars().take(30).collect::<String>());
416 let url_escaped = Self::xml_escape(url);
417
418 let mut svg = format!(
419 "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {width} {height}\" width=\"{width}\" height=\"{height}\" style=\"background:#0f172a; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;\">\n\
420 <rect width=\"{width}\" height=\"80\" fill=\"#1e293b\" />\n\
421 <circle cx=\"25\" cy=\"40\" r=\"7\" fill=\"#ef4444\" />\n\
422 <circle cx=\"45\" cy=\"40\" r=\"7\" fill=\"#f59e0b\" />\n\
423 <circle cx=\"65\" cy=\"40\" r=\"7\" fill=\"#10b981\" />\n\
424 <rect x=\"90\" y=\"24\" width=\"900\" height=\"32\" rx=\"6\" fill=\"#0f172a\" stroke=\"#334155\" stroke-width=\"1\" />\n\
425 <text x=\"105\" y=\"45\" fill=\"#94a3b8\" font-size=\"13\">🔒 {url_escaped}</text>\n\
426 <text x=\"1010\" y=\"45\" fill=\"#e2e8f0\" font-size=\"13\" font-weight=\"600\">{title_escaped}</text>\n\
427 <g transform=\"translate(60, 100)\">\n"
428 );
429
430 let mut wireframe_lines = Vec::new();
431 wireframe_lines.push(
432 "╔══════════════════════════════════════════════════════════════════════════════╗"
433 .to_string(),
434 );
435 wireframe_lines.push(format!("║ URL: {:<72} ║", url));
436 wireframe_lines.push(format!("║ TITLE: {:<70} ║", title));
437 wireframe_lines.push(
438 "╠══════════════════════════════════════════════════════════════════════════════╣"
439 .to_string(),
440 );
441
442 for (tag, text, index_opt) in &visual_blocks {
443 let truncated_text: String = text.chars().take(80).collect();
444 let escaped_text = Self::xml_escape(&truncated_text);
445
446 match tag.as_str() {
447 "h1" => {
448 svg.push_str(&format!(
449 " <text x=\"0\" y=\"{}\" fill=\"#f8fafc\" font-size=\"24\" font-weight=\"bold\">{}</text>\n",
450 y_offset, escaped_text
451 ));
452 wireframe_lines.push(format!("║ # {:<74} ║", truncated_text));
453 y_offset += 40;
454 }
455 "h2" => {
456 svg.push_str(&format!(
457 " <text x=\"0\" y=\"{}\" fill=\"#38bdf8\" font-size=\"18\" font-weight=\"bold\">{}</text>\n",
458 y_offset, escaped_text
459 ));
460 wireframe_lines.push(format!("║ ## {:<73} ║", truncated_text));
461 y_offset += 32;
462 }
463 _ => {
464 let badge = index_opt.map(|i| format!("[{}] ", i)).unwrap_or_default();
465 let color = if index_opt.is_some() {
466 "#60a5fa"
467 } else {
468 "#cbd5e1"
469 };
470 svg.push_str(&format!(
471 " <text x=\"0\" y=\"{}\" fill=\"{}\" font-size=\"13\">{}{}</text>\n",
472 y_offset, color, badge, escaped_text
473 ));
474 if index_opt.is_some() {
475 wireframe_lines.push(format!("║ [LINK] {}{:<67} ║", badge, truncated_text));
476 } else {
477 wireframe_lines.push(format!("║ {:<76} ║", truncated_text));
478 }
479 y_offset += 22;
480 }
481 }
482
483 if y_offset > 1100 {
484 break;
485 }
486 }
487
488 svg.push_str(" </g>\n</svg>");
489 wireframe_lines.push(
490 "╚══════════════════════════════════════════════════════════════════════════════╝"
491 .to_string(),
492 );
493
494 let png_bytes = Self::render_png(&svg, width, height).unwrap_or_default();
495 let png_base64 = if !png_bytes.is_empty() {
496 format!(
497 "data:image/png;base64,{}",
498 base64::engine::general_purpose::STANDARD.encode(&png_bytes)
499 )
500 } else {
501 String::new()
502 };
503
504 ScreenshotResult {
505 width,
506 height,
507 svg,
508 layout_wireframe: wireframe_lines.join("\n"),
509 element_count: visual_blocks.len(),
510 png_bytes,
511 png_base64,
512 }
513 }
514
515 pub fn render_png(svg_str: &str, width: u32, height: u32) -> Result<Vec<u8>> {
516 let opt = resvg::usvg::Options {
517 font_family: "sans-serif".to_string(),
518 ..Default::default()
519 };
520 let tree = resvg::usvg::Tree::from_str(svg_str, &opt)?;
521 let mut pixmap = resvg::tiny_skia::Pixmap::new(width, height)
522 .ok_or_else(|| anyhow::anyhow!("Failed to allocate raster pixmap"))?;
523
524 resvg::render(
525 &tree,
526 resvg::tiny_skia::Transform::default(),
527 &mut pixmap.as_mut(),
528 );
529 let png_data = pixmap.encode_png()?;
530 Ok(png_data)
531 }
532
533 fn xml_escape(s: &str) -> String {
534 s.replace('&', "&")
535 .replace('<', "<")
536 .replace('>', ">")
537 .replace('"', """)
538 .replace('\'', "'")
539 }
540}