Skip to main content

document_svg/code/
mod.rs

1//! Code generation from SVG: React JSX/TSX, Vue 3 components, and CSS Data URIs.
2
3use base64::Engine;
4use base64::engine::general_purpose::STANDARD as BASE64;
5
6use crate::error::{Error, Result};
7
8/// Converts SVG bytes to a React JSX or TSX component.
9pub fn svg_to_jsx(svg_bytes: &[u8], component_name: &str, is_typescript: bool) -> Result<String> {
10    let svg_str = std::str::from_utf8(svg_bytes)
11        .map_err(|e| Error::InvalidInput(format!("SVG is not valid UTF-8: {e}")))?;
12
13    let stripped = strip_xml_comments(svg_str);
14    let clean_svg = sanitize_for_jsx(&stripped);
15    let mut out = String::new();
16
17    if is_typescript {
18        out.push_str("import React from 'react';\n\n");
19        out.push_str(&format!("export const {component_name}: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (\n"));
20    } else {
21        out.push_str("import React from 'react';\n\n");
22        out.push_str(&format!("export const {component_name} = (props) => (\n"));
23    }
24
25    // Filter out XML declaration and DOCTYPE which are invalid in JSX
26    let filtered_lines: Vec<&str> = clean_svg
27        .lines()
28        .filter(|line| {
29            let trimmed = line.trim();
30            !trimmed.starts_with("<?xml") && !trimmed.starts_with("<!DOCTYPE")
31        })
32        .collect();
33
34    let mut svg_started = false;
35    for line in filtered_lines {
36        let trimmed = line.trim_start();
37        if !svg_started && trimmed.starts_with("<svg") {
38            svg_started = true;
39            let replaced = line.replace("<svg", "<svg {...props}");
40            out.push_str(&format!("  {replaced}\n"));
41        } else {
42            out.push_str(&format!("  {line}\n"));
43        }
44    }
45
46    out.push_str(");\n\nexport default ");
47    out.push_str(component_name);
48    out.push_str(";\n");
49
50    Ok(out)
51}
52
53/// Converts SVG bytes to a Vue 3 SFC component.
54pub fn svg_to_vue(svg_bytes: &[u8]) -> Result<String> {
55    let svg_str = std::str::from_utf8(svg_bytes)
56        .map_err(|e| Error::InvalidInput(format!("SVG is not valid UTF-8: {e}")))?;
57
58    let stripped = strip_xml_comments(svg_str);
59    let mut out = String::new();
60    out.push_str("<template>\n");
61    for line in stripped.lines() {
62        let trimmed = line.trim();
63        if trimmed.starts_with("<?xml") || trimmed.starts_with("<!DOCTYPE") {
64            continue;
65        }
66        out.push_str(&format!("  {line}\n"));
67    }
68    out.push_str("</template>\n\n<script setup>\n// Vue 3 SVG Icon component\n</script>\n");
69    Ok(out)
70}
71
72/// Converts SVG bytes to a base64 Data URI string.
73pub fn svg_to_data_uri(svg_bytes: &[u8]) -> Result<String> {
74    let encoded = BASE64.encode(svg_bytes);
75    Ok(format!("data:image/svg+xml;base64,{encoded}"))
76}
77
78/// Converts SVG bytes to a Svelte 3/4/5 icon component.
79pub fn svg_to_svelte(svg_bytes: &[u8]) -> Result<String> {
80    let svg_str = std::str::from_utf8(svg_bytes)
81        .map_err(|e| Error::InvalidInput(format!("SVG is not valid UTF-8: {e}")))?;
82
83    let stripped = strip_xml_comments(svg_str);
84    let mut out = String::from("<script>\n  // Svelte SVG Icon component\n</script>\n\n");
85    let filtered_lines: Vec<&str> = stripped
86        .lines()
87        .filter(|line| {
88            let trimmed = line.trim();
89            !trimmed.starts_with("<?xml") && !trimmed.starts_with("<!DOCTYPE")
90        })
91        .collect();
92
93    let mut svg_started = false;
94    for line in filtered_lines {
95        let trimmed = line.trim_start();
96        if !svg_started && trimmed.starts_with("<svg") {
97            svg_started = true;
98            let replaced = line.replace("<svg", "<svg {...$$restProps}");
99            out.push_str(&format!("{replaced}\n"));
100        } else {
101            out.push_str(&format!("{line}\n"));
102        }
103    }
104    Ok(out)
105}
106
107/// Wraps SVG bytes in a standalone, responsive HTML5 viewer document.
108pub fn svg_to_html(svg_bytes: &[u8], title: &str) -> Result<String> {
109    let svg_str = std::str::from_utf8(svg_bytes)
110        .map_err(|e| Error::InvalidInput(format!("SVG is not valid UTF-8: {e}")))?;
111
112    let stripped = strip_xml_comments(svg_str);
113    let mut out = String::new();
114    out.push_str("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n");
115    out.push_str("  <meta charset=\"utf-8\">\n");
116    out.push_str("  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n");
117    out.push_str(&format!("  <title>{}</title>\n", html_escape(title)));
118    out.push_str("  <style>\n");
119    out.push_str("    * { box-sizing: border-box; margin: 0; padding: 0; }\n");
120    out.push_str("    body {\n");
121    out.push_str("      display: flex;\n");
122    out.push_str("      justify-content: center;\n");
123    out.push_str("      align-items: center;\n");
124    out.push_str("      min-height: 100vh;\n");
125    out.push_str("      background-color: #f8fafc;\n");
126    out.push_str("      padding: 1.5rem;\n");
127    out.push_str("    }\n");
128    out.push_str("    @media (prefers-color-scheme: dark) {\n");
129    out.push_str("      body { background-color: #0f172a; }\n");
130    out.push_str("    }\n");
131    out.push_str("    .svg-container {\n");
132    out.push_str("      max-width: 100%;\n");
133    out.push_str("      max-height: 100vh;\n");
134    out.push_str("      display: flex;\n");
135    out.push_str("      justify-content: center;\n");
136    out.push_str("      align-items: center;\n");
137    out.push_str("    }\n");
138    out.push_str("    svg {\n");
139    out.push_str("      max-width: 100%;\n");
140    out.push_str("      height: auto;\n");
141    out.push_str(
142        "      box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);\n",
143    );
144    out.push_str("      border-radius: 4px;\n");
145    out.push_str("    }\n");
146    out.push_str("  </style>\n</head>\n<body>\n  <div class=\"svg-container\">\n");
147
148    for line in stripped.lines() {
149        let trimmed = line.trim();
150        if trimmed.starts_with("<?xml") || trimmed.starts_with("<!DOCTYPE") {
151            continue;
152        }
153        out.push_str("    ");
154        out.push_str(line);
155        out.push('\n');
156    }
157
158    out.push_str("  </div>\n</body>\n</html>\n");
159    Ok(out)
160}
161
162fn html_escape(s: &str) -> String {
163    s.replace('&', "&amp;")
164        .replace('<', "&lt;")
165        .replace('>', "&gt;")
166        .replace('"', "&quot;")
167        .replace('\'', "&#39;")
168}
169
170/// Extracts raw SVG path definition data (`d="..."`) from SVG bytes.
171pub fn svg_to_path_data(svg_bytes: &[u8]) -> Result<String> {
172    let mut reader = quick_xml::Reader::from_reader(svg_bytes);
173    reader.config_mut().trim_text(true);
174    let mut buffer = Vec::new();
175    let mut paths = Vec::new();
176
177    while let Ok(event) = reader.read_event_into(&mut buffer) {
178        match event {
179            quick_xml::events::Event::Start(e) | quick_xml::events::Event::Empty(e) => {
180                let name = e.name();
181                if name.as_ref() == b"path" {
182                    for attr in e.attributes().flatten() {
183                        if attr.key.as_ref() == b"d" {
184                            let val = String::from_utf8_lossy(&attr.value).into_owned();
185                            if !val.trim().is_empty() {
186                                paths.push(val);
187                            }
188                        }
189                    }
190                }
191            }
192            quick_xml::events::Event::Eof => break,
193            _ => {}
194        }
195        buffer.clear();
196    }
197
198    Ok(paths.join("\n"))
199}
200
201fn strip_xml_comments(s: &str) -> String {
202    let mut result = String::with_capacity(s.len());
203    let mut remaining = s;
204    while let Some(start) = remaining.find("<!--") {
205        result.push_str(&remaining[..start]);
206        if let Some(end) = remaining[start + 4..].find("-->") {
207            remaining = &remaining[start + 4 + end + 3..];
208        } else {
209            remaining = "";
210            break;
211        }
212    }
213    result.push_str(remaining);
214    result
215}
216
217fn sanitize_for_jsx(svg: &str) -> String {
218    // Transform common kebab-case SVG attributes to React camelCase
219    let mut s = svg.to_string();
220    let mappings = [
221        ("class=", "className="),
222        ("clip-path=", "clipPath="),
223        ("clip-rule=", "clipRule="),
224        ("fill-opacity=", "fillOpacity="),
225        ("fill-rule=", "fillRule="),
226        ("stroke-dasharray=", "strokeDasharray="),
227        ("stroke-dashoffset=", "strokeDashoffset="),
228        ("stroke-linecap=", "strokeLinecap="),
229        ("stroke-linejoin=", "strokeLinejoin="),
230        ("stroke-miterlimit=", "strokeMiterlimit="),
231        ("stroke-opacity=", "strokeOpacity="),
232        ("stroke-width=", "strokeWidth="),
233        ("font-family=", "fontFamily="),
234        ("font-size=", "fontSize="),
235        ("font-weight=", "fontWeight="),
236        ("font-style=", "fontStyle="),
237        ("text-anchor=", "textAnchor="),
238        ("dominant-baseline=", "dominantBaseline="),
239        ("stop-color=", "stopColor="),
240        ("stop-opacity=", "stopOpacity="),
241        ("xmlns:xlink=", "xmlnsXlink="),
242        ("xlink:href=", "xlinkHref="),
243        ("xml:space=", "xmlSpace="),
244        ("color-interpolation-filters=", "colorInterpolationFilters="),
245        ("flood-color=", "floodColor="),
246        ("flood-opacity=", "floodOpacity="),
247        ("lighting-color=", "lightingColor="),
248        ("pointer-events=", "pointerEvents="),
249        ("tabindex=", "tabIndex="),
250    ];
251
252    for (kebab, camel) in mappings {
253        s = s.replace(kebab, camel);
254    }
255    transform_inline_styles_for_jsx(&s)
256}
257
258fn kebab_to_camel(s: &str) -> String {
259    let mut out = String::new();
260    let mut capitalize_next = false;
261    for ch in s.chars() {
262        if ch == '-' {
263            capitalize_next = true;
264        } else if capitalize_next {
265            out.extend(ch.to_uppercase());
266            capitalize_next = false;
267        } else {
268            out.push(ch);
269        }
270    }
271    out
272}
273
274fn convert_inline_style_to_jsx(style_str: &str) -> String {
275    let mut entries = Vec::new();
276    for item in style_str.split(';') {
277        let item = item.trim();
278        if item.is_empty() {
279            continue;
280        }
281        if let Some((prop, val)) = item.split_once(':') {
282            let prop_trimmed = prop.trim();
283            let val_trimmed = val.trim();
284            let camel_prop = kebab_to_camel(prop_trimmed);
285            let escaped_val = val_trimmed.replace('\'', "\\'");
286            entries.push(format!("{camel_prop}: '{escaped_val}'"));
287        }
288    }
289    if entries.is_empty() {
290        "style={{}}".to_string()
291    } else {
292        format!("style={{{{ {} }}}}", entries.join(", "))
293    }
294}
295
296fn transform_inline_styles_for_jsx(svg: &str) -> String {
297    let mut out = String::with_capacity(svg.len());
298    let mut remaining = svg;
299
300    while let Some(pos) = remaining.find("style=") {
301        out.push_str(&remaining[..pos]);
302        let after_style = &remaining[pos + 6..];
303        if let Some(quote) = after_style.chars().next()
304            && (quote == '"' || quote == '\'')
305        {
306            let after_quote = &after_style[1..];
307            if let Some(end_quote) = after_quote.find(quote) {
308                let style_val = &after_quote[..end_quote];
309                let jsx_style = convert_inline_style_to_jsx(style_val);
310                out.push_str(&jsx_style);
311                remaining = &after_quote[end_quote + 1..];
312                continue;
313            }
314        }
315        out.push_str("style=");
316        remaining = after_style;
317    }
318    out.push_str(remaining);
319    out
320}