/** * Utilities shared by the SVG viewer and any other surface that wants to * format, measure, or deep-link an SVG document (icon detail page, etc.). * * Browser-only: relies on DOMParser and `Blob`. */ export interface SvgMetrics { bytes: number; width: string | null; height: string | null; viewBox: string | null; elementCount: number; hasGradients: boolean; hasMasks: boolean; rootFill: string | null; } export function isLikelySvg(text: string): boolean { const trimmed = text.trim(); return /^]/i.test(trimmed) && /<\/svg>\s*$/i.test(trimmed); } export function computeSvgMetrics(source: string): SvgMetrics | null { if (typeof window === "undefined") return null; if (!isLikelySvg(source)) return null; try { const parser = new DOMParser(); const doc = parser.parseFromString(source, "image/svg+xml"); const root = doc.documentElement; if (root.nodeName.toLowerCase() !== "svg") return null; return { bytes: new Blob([source]).size, width: root.getAttribute("width"), height: root.getAttribute("height"), viewBox: root.getAttribute("viewBox"), elementCount: doc.querySelectorAll("*").length - 1, hasGradients: doc.querySelectorAll("linearGradient, radialGradient").length > 0, hasMasks: doc.querySelectorAll("mask, clipPath").length > 0, rootFill: root.getAttribute("fill"), }; } catch { return null; } } export function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; } /** * Pretty-print SVG/XML with 2-space indentation. Handles self-closing tags, * processing instructions (), comments (), and CDATA blocks * by treating them as atomic nodes that do not change depth. * * Not a spec-compliant XML formatter — tuned for the brand SVGs we ship. */ export function formatSvg(source: string): string { const trimmed = source.trim(); if (!trimmed) return ""; const collapsed = trimmed.replace(/>\s+<"); let depth = 0; const out: string[] = []; let i = 0; const indent = () => " ".repeat(Math.max(0, depth)); while (i < collapsed.length) { if (collapsed[i] !== "<") { const next = collapsed.indexOf("<", i); const end = next === -1 ? collapsed.length : next; const text = collapsed.slice(i, end).trim(); if (text) out.push(indent() + text); i = end; continue; } if (collapsed.startsWith("", i); if (end === -1) break; out.push(indent() + collapsed.slice(i, end + 3)); i = end + 3; continue; } if (collapsed.startsWith("", i); if (end === -1) break; out.push(indent() + collapsed.slice(i, end + 3)); i = end + 3; continue; } if (collapsed[i + 1] === "?" || collapsed[i + 1] === "!") { const end = collapsed.indexOf(">", i); if (end === -1) break; out.push(indent() + collapsed.slice(i, end + 1)); i = end + 1; continue; } const end = collapsed.indexOf(">", i); if (end === -1) break; const tag = collapsed.slice(i, end + 1); const isClose = tag.startsWith(""); if (isClose) depth = Math.max(0, depth - 1); out.push(indent() + tag); if (!isClose && !isSelfClose) depth += 1; i = end + 1; } return out.join("\n"); } /** Minify by stripping whitespace between tags and trimming attribute runs. */ export function minifySvg(source: string): string { return source .trim() .replace(/>\s+<") .replace(/\s{2,}/g, " ") .replace(/\s*=\s*/g, "="); } /** * Strip the SVG payload of anything that could execute JavaScript before * it is handed to dangerouslySetInnerHTML. We're not running a full * sanitizer (no DOMPurify dep), but we remove the high-impact vectors: * * -