Spaces:
Running
Running
Create a web app to upload an SVG and turn it into code, as well as export SVG code as an SVG image.
fc2f058
verified
| /* SVG Code Converter: upload SVG, view/clean code, copy and export. | |
| Everything is done client-side. */ | |
| (() => { | |
| const els = { | |
| dropzone: document.getElementById('dropzone'), | |
| fileInput: document.getElementById('fileInput'), | |
| uploadError: document.getElementById('uploadError'), | |
| preview: document.getElementById('preview'), | |
| svgMeta: document.getElementById('svgMeta'), | |
| codeOutput: document.getElementById('codeOutput'), | |
| copyBtn: document.getElementById('copyBtn'), | |
| minifyToggle: document.getElementById('minifyToggle'), | |
| stripComments: document.getElementById('stripComments'), | |
| stripMetadata: document.getElementById('stripMetadata'), | |
| prettyPrint: document.getElementById('prettyPrint'), | |
| keepViewBox: document.getElementById('keepViewBox'), | |
| downloadSvgBtn: document.getElementById('downloadSvgBtn'), | |
| downloadPngBtn: document.getElementById('downloadPngBtn'), | |
| themeToggle: document.getElementById('themeToggle'), | |
| themeIcon: document.getElementById('themeIcon'), | |
| codeInfo: document.getElementById('codeInfo'), | |
| }; | |
| const state = { | |
| rawSvg: '', | |
| cleanSvg: '', | |
| fileName: 'image.svg', | |
| svgEl: null, | |
| dark: null, | |
| }; | |
| // Theme handling | |
| const initTheme = () => { | |
| const stored = localStorage.getItem('theme'); | |
| const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; | |
| state.dark = stored ? stored === 'dark' : prefersDark; | |
| document.documentElement.classList.toggle('dark', state.dark); | |
| setThemeIcon(); | |
| }; | |
| const setThemeIcon = () => { | |
| els.themeIcon.innerHTML = state.dark | |
| ? '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>' | |
| : '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/>'; | |
| }; | |
| els.themeToggle.addEventListener('click', () => { | |
| state.dark = !state.dark; | |
| document.documentElement.classList.toggle('dark', state.dark); | |
| localStorage.setItem('theme', state.dark ? 'dark' : 'light'); | |
| setThemeIcon(); | |
| }); | |
| // Utilities | |
| const showError = (msg) => { | |
| els.uploadError.textContent = msg; | |
| els.uploadError.classList.remove('hidden'); | |
| setTimeout(() => els.uploadError.classList.add('hidden'), 5000); | |
| }; | |
| const fmtBytes = (n) => { | |
| if (n < 1024) return `${n} B`; | |
| if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; | |
| return `${(n / (1024 * 1024)).toFixed(2)} MB`; | |
| }; | |
| const decodeSvgText = (text) => { | |
| // Remove BOM and decode numeric entities robustly | |
| return text.replace(/^\uFEFF/, '').replace(/ /g, ' '); | |
| }; | |
| const stripComments = (str) => str.replace(/<!--([\s\S]*?)-->/g, ''); | |
| const stripSvgMetadata = (str) => { | |
| // Remove sodipodi/inkscape metadata, editors' data, and XML processing instructions | |
| return str | |
| .replace(/<\?xml[\s\S]*?\?>/g, '') | |
| .replace(/<!DOCTYPE[\s\S]*?>/gi, '') | |
| .replace(/\s+(sodipodi:|inkscape:|xml:space)="[^"]*"/gi, '') | |
| .replace(/\s+(sodipodi:|inkscape:)[^=\s>]+/gi, '') | |
| .replace(/\s+data-name="[^"]*"/gi, '') | |
| .replace(/>\s+</g, '><') | |
| .trim(); | |
| }; | |
| const ensureViewBox = (svgString) => { | |
| // If viewBox is present, keep it; otherwise try to infer from width/height | |
| const hasViewBox = /viewBox\s*=/.test(svgString); | |
| if (hasViewBox) return svgString; | |
| const widthMatch = svgString.match(/\bwidth\s*=\s*["']?([0-9.]+)(px)?["']?/i); | |
| const heightMatch = svgString.match(/\bheight\s*=\s*["']?([0-9.]+)(px)?["']?/i); | |
| if (widthMatch && heightMatch) { | |
| const w = parseFloat(widthMatch[1]); | |
| const h = parseFloat(heightMatch[1]); | |
| if (isFinite(w) && isFinite(h) && w > 0 && h > 0) { | |
| // Insert or replace viewBox right after opening <svg ... > | |
| return svgString.replace( | |
| /<svg([^>]*?)>/i, | |
| `<svg$1 viewBox="0 0 ${w} ${h}">` | |
| ); | |
| } | |
| } | |
| return svgString; | |
| }; | |
| const cleanSvg = (raw, opts) => { | |
| let s = raw; | |
| // Normalize newlines and decode | |
| s = decodeSvgText(s.replace(/\r\n?/g, '\n')); | |
| if (opts.stripComments) s = stripComments(s); | |
| if (opts.stripMetadata) s = stripSvgMetadata(s); | |
| if (opts.minify) { | |
| // Minify: remove unnecessary whitespace between tags, collapse spaces between attributes | |
| s = s | |
| .replace(/>\s+</g, '><') | |
| .replace(/\s{2,}/g, ' ') | |
| .replace(/\s*=\s*/g, '=') | |
| .replace(/\s*([<>"'])\s*/g, '$1') | |
| .trim(); | |
| } else if (opts.pretty) { | |
| s = prettyXml(s); | |
| } | |
| if (opts.keepViewBox) { | |
| s = ensureViewBox(s); | |
| } | |
| return s; | |
| }; | |
| const prettyXml = (xml) => { | |
| // Simple pretty printer for XML/SVG | |
| try { | |
| const PADDING = ' '; | |
| const reg = /(>)(<)(\/*)/g; | |
| let xmlStr = xml.replace(reg, '$1\r\n$2$3'); | |
| let pad = 0; | |
| return xmlStr.split('\r\n').map((line) => { | |
| if (line.match(/.+<\/\w[^>]*>$/)) { | |
| // Single line | |
| return (PADDING.repeat(pad)) + line; | |
| } | |
| if (line.match(/^<\/\w/)) { | |
| // Closing tag | |
| pad = Math.max(pad - 1, 0); | |
| return (PADDING.repeat(pad)) + line; | |
| } | |
| if (line.match(/^<\w[^>]*[^\/]>.*$/)) { | |
| // Opening tag | |
| const out = (PADDING.repeat(pad)) + line; | |
| pad += 1; | |
| return out; | |
| } | |
| return (PADDING.repeat(pad)) + line; | |
| }).join('\n'); | |
| } catch { | |
| return xml; | |
| } | |
| }; | |
| const updateCodeInfo = () => { | |
| const text = els.codeOutput.value; | |
| const lines = (text.match(/\n/g) || []).length + 1; | |
| const chars = text.length.toLocaleString(); | |
| els.codeInfo.textContent = `${lines} lines • ${chars} chars`; | |
| }; | |
| const renderPreview = (svgString) => { | |
| els.preview.innerHTML = ''; | |
| if (!svgString) { | |
| const div = document.createElement('div'); | |
| div.className = 'text-center text-gray-500'; | |
| div.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" class="mx-auto w-10 h-10 mb-2 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg><p>SVG preview will appear here</p>'; | |
| els.preview.appendChild(div); | |
| return; | |
| } | |
| const wrapper = document.createElement('div'); | |
| wrapper.className = 'w-full h-full flex items-center justify-center overflow-auto'; | |
| // Parse and insert safely | |
| const parser = new DOMParser(); | |
| const doc = parser.parseFromString(svgString, 'image/svg+xml'); | |
| const parseError = doc.querySelector('parsererror'); | |
| if (parseError) { | |
| const err = document.createElement('div'); | |
| err.className = 'text-red-600 text-sm'; | |
| err.textContent = 'Invalid SVG: ' + parseError.textContent; | |
| els.preview.appendChild(err); | |
| return; | |
| } | |
| const svg = doc.documentElement; | |
| svg.classList.remove('hidden'); | |
| svg.removeAttribute('width'); | |
| svg.removeAttribute('height'); | |
| svg.setAttribute('preserveAspectRatio', 'xMidYMid meet'); | |
| svg.style.maxWidth = '100%'; | |
| svg.style.maxHeight = '100%'; | |
| wrapper.appendChild(svg); | |
| els.preview.appendChild(wrapper); | |
| state.svgEl = svg; | |
| }; | |
| const updateAll = () => { | |
| const opts = { | |
| minify: els.minifyToggle.checked, | |
| stripComments: els.stripComments.checked, | |
| stripMetadata: els.stripMetadata.checked, | |
| pretty: els.prettyPrint.checked && !els.minifyToggle.checked, | |
| keepViewBox: els.keepViewBox.checked, | |
| }; | |
| const cleaned = cleanSvg(state.rawSvg, opts); | |
| state.cleanSvg = cleaned; | |
| els.codeOutput.value = cleaned; | |
| updateCodeInfo(); | |
| renderPreview(cleaned); | |
| els.downloadSvgBtn.disabled = !cleaned; | |
| els.downloadPngBtn.disabled = !cleaned; | |
| }; | |
| const handleFile = (file) => { | |
| if (!file) return; | |
| if (!/\.svg$/i.test(file.name) && file.type !== 'image/svg+xml') { | |
| showError('Please select a valid SVG file.'); | |
| return; | |
| } | |
| const maxSize = 5 * 1024 * 1024; | |
| if (file.size > maxSize) { | |
| showError('File is too large. Max 5 MB.'); | |
| return; | |
| } | |
| state.fileName = file.name.endsWith('.svg') ? file.name : (file.name.replace(/\.[^.]+$/, '') + '.svg'); | |
| const reader = new FileReader(); | |
| reader.onload = () => { | |
| state.rawSvg = String(reader.result || ''); | |
| updateAll(); | |
| els.svgMeta.textContent = `${file.name} • ${fmtBytes(file.size)}`; | |
| }; | |
| reader.onerror = () => showError('Failed to read the file.'); | |
| reader.readAsText(file); | |
| }; | |
| // Drag & drop | |
| const preventDefaults = (e) => { e.preventDefault(); e.stopPropagation(); }; | |
| ['dragenter', 'dragover', 'dragleave', 'drop'].forEach((evt) => { | |
| els.dropzone.addEventListener(evt, preventDefaults, false); | |
| }); | |
| els.dropzone.addEventListener('dragover', () => { | |
| els.dropzone.classList.add('border-primary-400'); | |
| }); | |
| els.dropzone.addEventListener('dragleave', () => { | |
| els.dropzone.classList.remove('border-primary-400'); | |
| }); | |
| els.dropzone.addEventListener('drop', (e) => { | |
| els.dropzone.classList.remove('border-primary-400'); | |
| const file = e.dataTransfer.files?.[0]; | |
| handleFile(file); | |
| }); | |
| els.dropzone.addEventListener('click', () => els.fileInput.click()); | |
| els.fileInput.addEventListener('change', () => handleFile(els.fileInput.files?.[0])); | |
| // Controls | |
| els.copyBtn.addEventListener('click', async () => { | |
| try { | |
| await navigator.clipboard.writeText(els.codeOutput.value); | |
| const original = els.copyBtn.textContent; | |
| els.copyBtn.textContent = 'Copied!'; | |
| setTimeout(() => (els.copyBtn.textContent = original), 1200); | |
| } catch { | |
| showError('Clipboard write failed. Select the code and copy manually.'); | |
| } | |
| }); | |
| [els.minifyToggle, els.stripComments, els.stripMetadata, els.prettyPrint, els.keepViewBox].forEach((el) => { | |
| el.addEventListener('change', updateAll); | |
| }); | |
| els.downloadSvgBtn.addEventListener('click', () => { | |
| if (!state.cleanSvg) return; | |
| const blob = new Blob([state.cleanSvg], { type: 'image/svg+xml;charset=utf-8' }); | |
| const a = document.createElement('a'); | |
| a.href = URL.createObjectURL(blob); | |
| a.download = state.fileName || 'image.svg'; | |
| document.body.appendChild(a); | |
| a.click(); | |
| a.remove(); | |
| setTimeout(() => URL.revokeObjectURL(a.href), 2000); | |
| }); | |
| els.downloadPngBtn.addEventListener('click', async () => { | |
| if (!state.cleanSvg) return; | |
| try { | |
| const { image, width, height } = await rasterizeSvg(state.cleanSvg, 2); // 2x scale | |
| const a = document.createElement('a'); | |
| a.href = image.src; | |
| a.download = (state.fileName || 'image').replace(/\.svg$/i, '') + '.png'; | |
| document.body.appendChild(a); | |
| a.click(); | |
| a.remove(); | |
| } catch (err) { | |
| console.error(err); | |
| showError('Failed to export PNG.'); | |
| } | |
| }); | |
| const rasterizeSvg = (svgString, scale = 1) => { | |
| return new Promise((resolve, reject) => { | |
| const svgBlob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' }); | |
| const url = URL.createObjectURL(svgBlob); | |
| const img = new Image(); | |
| img.onload = () => { | |
| // Determine final dimensions | |
| const width = Math.max(1, Math.ceil(img.naturalWidth * scale) || 1024); | |
| const height = Math.max(1, Math.ceil(img.naturalHeight * scale) || 1024); | |
| const canvas = document.createElement('canvas'); | |
| canvas.width = width; | |
| canvas.height = height; | |
| const ctx = canvas.getContext('2d'); | |
| // Optional: white background for non-opaque exports | |
| ctx.fillStyle = 'white'; | |
| ctx.fillRect(0, 0, width, height); | |
| ctx.drawImage(img, 0, 0, width, height); | |
| canvas.toBlob((blob) => { | |
| if (!blob) { | |
| URL.revokeObjectURL(url); | |
| reject(new Error('Canvas toBlob failed')); | |
| return; | |
| } | |
| const image = new Image(); | |
| image.onload = () => { | |
| URL.revokeObjectURL(url); | |
| resolve({ image, width, height }); | |
| }; | |
| image.onerror = () => { | |
| URL.revokeObjectURL(url); | |
| reject(new Error('Image load failed')); | |
| }; | |
| image.src = URL.createObjectURL(blob); | |
| }, 'image/png'); | |
| }; | |
| img.onerror = () => { | |
| URL.revokeObjectURL(url); | |
| reject(new Error('Invalid SVG')); | |
| }; | |
| img.src = url; | |
| }); | |
| }; | |
| // Initialize | |
| initTheme(); | |
| updateAll(); | |
| })(); |