headless_engine/render/
paint.rs1use crate::render::layout::{LayoutBox, LayoutContent};
2use anyhow::Result;
3use base64::Engine;
4use std::collections::HashMap;
5
6pub struct PaintEngine;
7
8impl PaintEngine {
9 pub async fn paint_to_svg_and_png(
10 url: &str,
11 title: &str,
12 root_box: &LayoutBox,
13 width: u32,
14 height: u32,
15 ) -> Result<(String, Vec<u8>)> {
16 let mut svg_body = String::new();
17
18 let url_escaped = Self::xml_escape(url);
20 let title_escaped = Self::xml_escape(&title.chars().take(35).collect::<String>());
21
22 let header = format!(
23 " <!-- Browser Navigation Bar -->\n\
24 <rect width=\"{width}\" height=\"56\" fill=\"#1e293b\" />\n\
25 <circle cx=\"22\" cy=\"28\" r=\"6\" fill=\"#ef4444\" />\n\
26 <circle cx=\"40\" cy=\"28\" r=\"6\" fill=\"#f59e0b\" />\n\
27 <circle cx=\"58\" cy=\"28\" r=\"6\" fill=\"#10b981\" />\n\
28 <rect x=\"80\" y=\"12\" width=\"800\" height=\"32\" rx=\"16\" fill=\"#0f172a\" stroke=\"#334155\" stroke-width=\"1\" />\n\
29 <text x=\"98\" y=\"32\" fill=\"#94a3b8\" font-size=\"12\">🔒 {url_escaped}</text>\n\
30 <text x=\"900\" y=\"32\" fill=\"#e2e8f0\" font-size=\"12\" font-weight=\"600\">{title_escaped}</text>\n\
31 <line x1=\"0\" y1=\"56\" x2=\"{width}\" y2=\"56\" stroke=\"#334155\" stroke-width=\"1\" />\n\
32 \n\
33 <!-- Webpage Render Canvas -->\n\
34 <g transform=\"translate(0, 56)\">\n"
35 );
36 svg_body.push_str(&header);
37
38 let mut image_urls = Vec::new();
40 Self::collect_image_urls(root_box, &mut image_urls);
41
42 let mut image_cache: HashMap<String, String> = HashMap::new();
43 let client = reqwest::Client::builder()
44 .timeout(std::time::Duration::from_millis(2000))
45 .build()
46 .ok();
47
48 if let Some(ref c) = client {
49 for img_url in image_urls.into_iter().take(12) {
50 if img_url.starts_with("http://") || img_url.starts_with("https://") {
51 if let Ok(resp) = c.get(&img_url).send().await {
52 if let Ok(bytes) = resp.bytes().await {
53 let mime = if img_url.ends_with(".png") {
54 "image/png"
55 } else if img_url.ends_with(".webp") {
56 "image/webp"
57 } else {
58 "image/jpeg"
59 };
60 let b64 = format!(
61 "data:{};base64,{}",
62 mime,
63 base64::engine::general_purpose::STANDARD.encode(&bytes)
64 );
65 image_cache.insert(img_url, b64);
66 }
67 }
68 }
69 }
70 }
71
72 Self::paint_box(root_box, &mut svg_body, &image_cache);
74
75 svg_body.push_str(" </g>\n</svg>");
76
77 let full_svg = format!(
78 "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" viewBox=\"0 0 {width} {height}\" width=\"{width}\" height=\"{height}\" style=\"background:#0f172a; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;\">\n\
79{body}",
80 width = width,
81 height = height,
82 body = svg_body
83 );
84
85 let opt = resvg::usvg::Options {
87 font_family: "sans-serif".to_string(),
88 ..Default::default()
89 };
90 let tree = resvg::usvg::Tree::from_str(&full_svg, &opt)?;
91 let mut pixmap = resvg::tiny_skia::Pixmap::new(width, height)
92 .ok_or_else(|| anyhow::anyhow!("Failed to allocate raster buffer"))?;
93
94 resvg::render(
95 &tree,
96 resvg::tiny_skia::Transform::default(),
97 &mut pixmap.as_mut(),
98 );
99 let png_bytes = pixmap.encode_png()?;
100
101 Ok((full_svg, png_bytes))
102 }
103
104 fn collect_image_urls(box_node: &LayoutBox, urls: &mut Vec<String>) {
105 match &box_node.content {
106 LayoutContent::Image { src, .. } => {
107 if !src.is_empty() {
108 urls.push(src.clone());
109 }
110 }
111 LayoutContent::Element { children, .. } => {
112 for child in children {
113 Self::collect_image_urls(child, urls);
114 }
115 }
116 _ => {}
117 }
118 }
119
120 fn paint_box(box_node: &LayoutBox, svg: &mut String, image_cache: &HashMap<String, String>) {
121 if box_node.style.is_hidden || box_node.rect.y > 1200.0 {
122 return;
123 }
124
125 let r = &box_node.rect;
126
127 if let Some(ref bg) = box_node.style.background_color {
129 if r.width > 0.0 && r.height > 0.0 {
130 svg.push_str(&format!(
131 " <rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" rx=\"{}\" fill=\"{}\" />\n",
132 r.x, r.y, r.width, r.height, box_node.style.border_radius, bg
133 ));
134 }
135 }
136
137 if let Some(ref bc) = box_node.style.border_color {
139 if box_node.style.border_width > 0.0 {
140 svg.push_str(&format!(
141 " <rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" rx=\"{}\" fill=\"none\" stroke=\"{}\" stroke-width=\"{}\" />\n",
142 r.x, r.y, r.width, r.height, box_node.style.border_radius, bc, box_node.style.border_width
143 ));
144 }
145 }
146
147 match &box_node.content {
149 LayoutContent::Text(txt) => {
150 if !txt.is_empty() {
151 let escaped = Self::xml_escape(txt);
152 let weight =
153 if box_node.style.font_weight == crate::render::css::FontWeight::Bold {
154 " font-weight=\"bold\""
155 } else {
156 ""
157 };
158
159 let badge = if let Some(idx) = box_node.interactive_index {
160 format!("[{}] ", idx)
161 } else {
162 String::new()
163 };
164
165 svg.push_str(&format!(
166 " <text x=\"{}\" y=\"{}\" fill=\"{}\" font-size=\"{}\"{}>{}{}</text>\n",
167 r.x,
168 r.y + (box_node.style.font_size * 0.9),
169 box_node.style.color,
170 box_node.style.font_size,
171 weight,
172 badge,
173 escaped
174 ));
175 }
176 }
177 LayoutContent::Image { src, alt: _ } => {
178 if let Some(data_url) = image_cache.get(src) {
179 svg.push_str(&format!(
180 " <image href=\"{}\" xlink:href=\"{}\" x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" preserveAspectRatio=\"xMidYMid slice\" />\n",
181 data_url, data_url, r.x, r.y, r.width, r.height
182 ));
183 } else {
184 svg.push_str(&format!(
185 " <rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" rx=\"6\" fill=\"#1e293b\" stroke=\"#334155\" stroke-width=\"1\" />\n\
186 <text x=\"{}\" y=\"{}\" fill=\"#64748b\" font-size=\"11\">[Image]</text>\n",
187 r.x, r.y, r.width, r.height,
188 r.x + 10.0, r.y + 20.0
189 ));
190 }
191 }
192 LayoutContent::Element { children, .. } => {
193 for child in children {
195 Self::paint_box(child, svg, image_cache);
196 }
197 }
198 }
199 }
200
201 fn xml_escape(s: &str) -> String {
202 s.replace('&', "&")
203 .replace('<', "<")
204 .replace('>', ">")
205 .replace('"', """)
206 .replace('\'', "'")
207 }
208}