1use base64::Engine;
4use base64::engine::general_purpose::STANDARD as BASE64;
5
6use crate::error::{Error, Result};
7
8pub 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 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
53pub 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
72pub 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
78pub 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
107pub 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('&', "&")
164 .replace('<', "<")
165 .replace('>', ">")
166 .replace('"', """)
167 .replace('\'', "'")
168}
169
170pub 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 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}