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