/** * Custom svgo v3 plugin: removeBackground * * Detects and removes background shapes (rects, rounded-rect paths, large traced * bitmaps) from the SVG AST. Captures the removed element's fill color so the * caller can use it as `colorPrimary`. * * Usage: * const bg = createRemoveBackgroundPlugin() * // pass bg.plugin in svgoConfig.plugins * // after transform: bg.getBackgroundFill() */ import { colorToLuminance, isLargeShape, isNearWhiteFill, isWhiteFill, parseSvgPathBounds, parseViewBox } from './svg-utils' import type { CustomPlugin, XastChild, XastElement, XastParent, XastRoot } from './types' const { detachNodeFromParent } = require('svgo/lib/xast') as { detachNodeFromParent: (node: XastChild, parentNode: XastParent) => void } /** Regex patterns for rounded-rect path commands ([\d.]+ to handle decimal coords like 4.99999). */ const ROUNDED_RECT_PATTERNS = [ /^M[\d.]+[\s,]+0H[\d.]+[CA]/, // M18 0H6C... or M19.503 0H4.496A... (cubic/arc rounded corners) /^M0[\s,]+[\d.]+[CA]/, // M0 6C... or M0 6A... /^M0[\s,]+0[HVhv]/, // M0 0H... /^M[\d.]+[\s,]+[\d.]+H[\d.]+V[\d.]+H[\d.]+V[\d.]+Z?$/i // Simple rect ] function isRoundedRectPath(d: string): boolean { const trimmed = d.trim() return ROUNDED_RECT_PATTERNS.some((p) => p.test(trimmed)) } function pathCommandCount(d: string): number { return (d.match(/[a-zA-Z]/g) || []).length } interface PathInfo { node: XastElement parent: XastElement d: string area: number fill: string resolvedFill: string // Resolved from gradient url(#id) to actual color lum: number cmdCount: number isWhite: boolean } interface RemoveBackgroundOptions { /** If true, only detect background fill without removing elements. Used for color.tsx generation. */ detectOnly?: boolean } export function createRemoveBackgroundPlugin(options: RemoveBackgroundOptions = {}) { const { detectOnly = false } = options let backgroundFill: string | null = null let removed = false const plugin = { name: 'removeBackground', fn: (root: XastRoot) => { // Pre-scan: find element and extract viewBox let svgNode: XastElement | null = null for (const child of root.children) { if (child.type === 'element' && child.name === 'svg') { svgNode = child break } } if (!svgNode) return {} const vb = parseViewBox(svgNode.attributes) const vbArea = vb.w * vb.h // Collect gradient definitions for resolving url(#id) fills const gradients = new Map() function collectGradients(node: XastElement) { const children = node.children for (const child of children) { if (child.type !== 'element') continue if (child.name === 'linearGradient' || child.name === 'radialGradient') { const id = child.attributes.id if (id) { const stops = child.children.filter((s): s is XastElement => s.type === 'element' && s.name === 'stop') if (stops.length > 0) { // Use first stop color as representative brand color const color = stops[0].attributes['stop-color'] if (color) gradients.set(id, color) } } } if (child.children.length > 0) collectGradients(child) } } collectGradients(svgNode) function resolveGradientFill(fill: string): string { if (!fill || !fill.startsWith('url(')) return fill const m = fill.match(/url\(#([^)]+)\)/) return m ? gradients.get(m[1]) || '' : '' } // Collect all and info in document order const paths: PathInfo[] = [] const rects: { node: XastElement parent: XastElement w: number h: number fill: string resolvedFill: string }[] = [] function collectElements(node: XastElement) { const children = node.children for (const child of children) { if (child.type !== 'element') continue // Skip non-visual elements: , , if (child.name === 'defs' || child.name === 'mask' || child.name === 'clipPath') continue if (child.name === 'rect') { const w = parseFloat(child.attributes.width || '0') const h = parseFloat(child.attributes.height || '0') const fill = child.attributes.fill || '' const resolved = resolveGradientFill(fill) rects.push({ node: child, parent: node, w, h, fill, resolvedFill: resolved }) } if (child.name === 'path') { const d = child.attributes.d || '' if (!d) continue const fill = child.attributes.fill || '' const bounds = parseSvgPathBounds(d) let area = 0 if (isFinite(bounds.minX)) { // Account for transform="translate(x,y)" const transformAttr = child.attributes.transform || '' const tx = transformAttr.match(/translate\((-?[\d.]+)[,\s]+(-?[\d.]+)\)/) if (tx) { const dx = parseFloat(tx[1]) || 0 const dy = parseFloat(tx[2]) || 0 bounds.minX += dx bounds.maxX += dx bounds.minY += dy bounds.maxY += dy } area = (bounds.maxX - bounds.minX) * (bounds.maxY - bounds.minY) } const resolved = resolveGradientFill(fill) const lum = resolved ? colorToLuminance(resolved) : 0 // no fill attr → implicit black paths.push({ node: child, parent: node, d, area, fill, resolvedFill: resolved, lum, cmdCount: pathCommandCount(d), isWhite: isWhiteFill(resolved) }) } // Recurse into child elements (groups, etc.) if (child.children && child.children.length > 0) { collectElements(child) } } } collectElements(svgNode) // Helper: mark background detected, optionally remove from AST. // In detectOnly mode, white backgrounds (fill === null) are still removed // since they are never brand elements and break dark mode rendering. function markBackground(node: XastElement, parent: XastElement, fill: string | null) { if (fill) backgroundFill = fill if (!detectOnly || !fill) detachNodeFromParent(node, parent) removed = true } // --- Rule 1: covering >= 70% viewBox → remove --- for (const rect of rects) { if (rect.w >= vb.w * 0.7 && rect.h >= vb.h * 0.7) { const color = rect.resolvedFill || rect.fill const bg = color && !isWhiteFill(color) && color !== 'none' ? color : null markBackground(rect.node, rect.parent, bg) break } } if (!removed) { // --- Rule 2: First matching rounded-rect pattern AND >= 70% viewBox --- for (const p of paths) { if (isRoundedRectPath(p.d) && p.area >= vbArea * 0.7) { const color = p.resolvedFill || p.fill const bg = color && !isWhiteFill(color) && color !== 'none' ? color : null markBackground(p.node, p.parent, bg) break } } } if (!removed) { // Check if there are white foreground paths (indicates dark bg + light content pattern) const hasWhiteFg = paths.some((p) => p.isWhite) if (hasWhiteFg) { // Separate large non-white paths (potential backgrounds) const largeNonWhitePaths = paths.filter( (p) => !p.isWhite && p.fill !== 'none' && isLargeShape(p.d, vb.w, vb.h, 0.3) ) // --- Rule 3: Largest covering >= 90% viewBox AND dark (lum < 0.15) → remove --- const sorted = [...largeNonWhitePaths].sort((a, b) => b.area - a.area) if (sorted.length >= 1 && sorted[0].area >= vbArea * 0.9 && sorted[0].lum >= 0 && sorted[0].lum < 0.15) { markBackground(sorted[0].node, sorted[0].parent, sorted[0].resolvedFill || sorted[0].fill || '#000000') } // --- Rule 3b: Dominant dark shape covering >= 60% with multiple large shapes --- if ( !removed && sorted.length >= 2 && sorted[0].area >= vbArea * 0.6 && sorted[0].lum >= 0 && sorted[0].lum < 0.15 ) { markBackground(sorted[0].node, sorted[0].parent, sorted[0].resolvedFill || sorted[0].fill || '#000000') } } } if (!removed) { // --- Rule 3c: First covering >= 85% viewBox with near-white foreground --- // Targets vectorized (traced) backgrounds from @neplex/vectorizer output. // The vectorizer produces a large filled path as the first element (background), // followed by lighter detail paths (e.g. IBM icon with #002D72 bg and #CDDDEC/#EFF4F9 text). const hasNearWhiteFg = paths.some( (p) => isNearWhiteFill(p.resolvedFill || p.fill) && !isLargeShape(p.d, vb.w, vb.h, 0.85) ) if (hasNearWhiteFg && paths.length >= 2) { const first = paths[0] // Check that remaining paths are all light / white (true vectorized bg pattern). // If there are distinctly colored (low-luminance) paths among the remaining, the first // path is likely part of the icon design (e.g. aihubmix circle + white cutout + blue smile), // not a background. const remainingPaths = paths.slice(1) const hasColoredForeground = remainingPaths.some((p) => { const fill = p.resolvedFill || p.fill if (!fill || fill === 'none' || isWhiteFill(fill)) return false const lum = colorToLuminance(fill) return lum >= 0 && lum < 0.7 }) if ( !hasColoredForeground && first.area >= vbArea * 0.85 && first.lum >= 0 && first.lum < 0.5 && !isNearWhiteFill(first.resolvedFill || first.fill) ) { markBackground(first.node, first.parent, first.resolvedFill || first.fill || '#000000') } } } if (!removed) { // --- Rule 4: First large white covering >= 60% viewBox → remove --- for (const p of paths) { if (p.isWhite && p.area >= vbArea * 0.6 && p.cmdCount <= 10) { // Only remove simple white backgrounds, not complex white shapes (like huggingface body) markBackground(p.node, p.parent, null) break } } } return {} } } satisfies CustomPlugin return { plugin, getBackgroundFill: () => backgroundFill, wasRemoved: () => removed } }