Skip to main content

headless_engine/render/
paint.rs

1use 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        // 1. Browser Chrome Header Bar
19        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\">&#128274; {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        // 2. Fetch images concurrently if any
39        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        // 3. Paint layout box tree recursively
73        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        // 4. Rasterize to real PNG via pure-Rust resvg + tiny-skia
86        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        // Draw Background
128        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        // Draw Border
138        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        // Draw Content
148        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                // Paint children in document order
194                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('&', "&amp;")
203            .replace('<', "&lt;")
204            .replace('>', "&gt;")
205            .replace('"', "&quot;")
206            .replace('\'', "&apos;")
207    }
208}