svg-code-converter / script.js
namelessai's picture
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(/&nbsp;/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();
})();