#!/usr/bin/env python3
"""
SVG Icon Embedding Tool
Replaces icon placeholders in SVG files with actual icon code.
Placeholder syntax (new SVGs must include a library prefix):
Legacy compatibility accepted by the resolver:
-> chunk-filled/rocket
-> chunk-filled/rocket
Optional `stroke-width` (stroke-style libraries only — e.g. tabler-outline):
Default 2 (matches the source). Pass 1.5 for thin, 3 for bold.
Ignored on fill-style libraries.
After replacement:
Icon libraries (subdirectories of templates/icons/):
chunk-filled/ - 640+ fill icons, 16x16 viewBox (use prefix: chunk-filled/name; legacy 'chunk/' also accepted)
tabler-filled/ - 1000+ fill icons, 24x24 viewBox (use prefix: tabler-filled/name)
tabler-outline/ - 5000+ stroke icons, 24x24 viewBox (use prefix: tabler-outline/name)
phosphor-duotone/ - 1200+ duotone icons, 256x256 viewBox (single color + 0.2-opacity backplate)
simple-icons/ - 3400+ brand logos, 24x24 viewBox (brand-inset library — used alongside the chosen primary library, NOT as a standalone library for generic icons)
.svg - project-local extracted vector illustrations with data-icon-style="preserve-color"; preserve source colors and natural viewBox aspect ratio
Usage:
python3 scripts/svg_finalize/embed_icons.py [svg_file2] ...
python3 scripts/svg_finalize/embed_icons.py svg_output/*.svg
Options:
--icons-dir Icon directory path (default: templates/icons/)
--dry-run Only show what would be replaced, without modifying files
--verbose Show detailed information
"""
from __future__ import annotations
import os
import re
import sys
import argparse
from pathlib import Path
from xml.etree import ElementTree as ET
# Default icon directory
DEFAULT_ICONS_DIR = Path(__file__).parent.parent.parent / 'templates' / 'icons'
# Icon base size per library
ICON_BASE_SIZES = {
'chunk-filled': 16,
'chunk': 16, # backward compat alias → chunk-filled/
'tabler-filled': 24,
'tabler-outline': 24,
'phosphor-duotone': 256,
'simple-icons': 24,
}
DEFAULT_ICON_BASE_SIZE = 24
BaseGeometry = float | tuple[float, float, float, float]
def _get_viewbox_size(content: str) -> float:
"""Extract the width from viewBox attribute (assumed square). Returns 0 if not found."""
m = re.search(r'viewBox=["\']0 0 ([\d.]+)', content)
if m:
return float(m.group(1))
return 0
def _get_viewbox_geometry(content: str) -> tuple[float, float, float, float] | None:
"""Extract full viewBox geometry as (min_x, min_y, width, height)."""
match = re.search(r'viewBox=["\']([^"\']+)["\']', content)
if not match:
return None
parts = re.split(r'[\s,]+', match.group(1).strip())
if len(parts) < 4:
return None
try:
min_x, min_y, width, height = [float(part) for part in parts[:4]]
except ValueError:
return None
if width <= 0 or height <= 0:
return None
return min_x, min_y, width, height
def _format_number(value: object) -> str:
"""Format SVG numeric values compactly without losing meaningful precision."""
if isinstance(value, float):
return f'{value:g}'
return str(value)
def _base_geometry(base_size: BaseGeometry) -> tuple[float, float, float, float]:
"""Normalize legacy square icon size and full viewBox geometry."""
if isinstance(base_size, tuple):
return base_size
return 0.0, 0.0, float(base_size), float(base_size)
def _is_preserve_color_asset(content: str) -> bool:
"""Project illustrations are vector assets, not recolorable monochrome icons.
The `data-icon-style="preserve-color"` marker is stamped by
extract_svg_assets.py and is the single source of truth — hand-authored
multi-color assets must carry it to keep their colors and aspect ratio.
"""
return 'data-icon-style="preserve-color"' in content
def _detect_icon_style(content: str) -> str:
"""Detect whether an icon is fill-based or stroke-based."""
# stroke="currentColor" with fill="none" → stroke style
if 'stroke="currentColor"' in content and 'fill="none"' in content:
return 'stroke'
return 'fill'
def _extract_svg_body(content: str) -> list[str]:
"""Return the root SVG body for preserve-color assets without editing attrs."""
match = re.search(r'\s*$', content, re.DOTALL)
if not match:
return []
body = match.group(1).strip()
return [body] if body else []
def _extract_shape_elements(content: str, color: str) -> list[str]:
"""
Extract all drawable shape elements from an icon SVG, replacing
fill/stroke color references (currentColor or #xxxxxx) with the target color.
Supports: , , , , , ,
"""
shape_tags = ('path', 'circle', 'rect', 'line', 'polyline', 'polygon', 'ellipse')
pattern = r'<(' + '|'.join(shape_tags) + r')(\s[^>]*)?(?:/>|>\1>)'
matches = re.findall(pattern, content, re.DOTALL)
elements = []
for tag, attrs in matches:
# Remove standalone fill/stroke color attrs so outer controls color.
# Also strip stroke-width so the outer can override it (otherwise the
# icon's source stroke-width="2" would shadow any caller-specified value).
attrs_clean = re.sub(r'\s*fill="(?:currentColor|#[0-9a-fA-F]{3,6}|none)"', '', attrs)
attrs_clean = re.sub(r'\s*stroke="(?:currentColor|#[0-9a-fA-F]{3,6}|none)"', '', attrs_clean)
attrs_clean = re.sub(r'\s*stroke-width="[^"]*"', '', attrs_clean)
elements.append(f'<{tag}{attrs_clean}/>')
return elements
def _resolve_in_dir(icon_name: str, icons_dir: Path) -> tuple[Path, float]:
"""Resolve `icon_name` against a single icons dir (no fallback)."""
# Backward compat: 'chunk/name' → 'chunk-filled/name'
_LIB_ALIASES = {'chunk': 'chunk-filled'}
if '/' in icon_name:
lib, name = icon_name.split('/', 1)
lib = _LIB_ALIASES.get(lib, lib) # resolve aliases
icon_path = icons_dir / lib / f'{name}.svg'
base_size = ICON_BASE_SIZES.get(lib, 24)
else:
# Backward compatibility: un-prefixed names fall back to legacy chunk-filled/ library
icon_path = icons_dir / 'chunk-filled' / f'{icon_name}.svg'
base_size = 16
if not icon_path.exists():
icon_path = icons_dir / f'{icon_name}.svg' # legacy flat layout
base_size = 16
return icon_path, base_size
def resolve_icon_path(icon_name: str, icons_dir: Path, fallback_dir: Path | None = None) -> tuple[Path, float]:
"""
Resolve icon name to file path and base size, e.g. "chunk-filled/home" →
icons_dir/chunk-filled/home.svg. "chunk/" is a backward-compat alias; an
un-prefixed name falls back to chunk-filled/ then a legacy flat layout.
Resolution is project-first: if the icon is absent under ``icons_dir`` and a
``fallback_dir`` (the global library) is given, the fallback's path is
returned instead. Returns (path, base_size); the path may not exist when
neither dir has the icon.
"""
icon_path, base_size = _resolve_in_dir(icon_name, icons_dir)
if fallback_dir is not None and not icon_path.exists():
fb_path, fb_size = _resolve_in_dir(icon_name, fallback_dir)
if fb_path.exists():
return fb_path, fb_size
return icon_path, base_size
def extract_paths_from_icon(icon_path: Path, target_color: str = '#000000') -> tuple[list[str], str, BaseGeometry]:
"""
Extract drawable elements from an icon SVG file.
Returns:
(elements, style, base_size)
style: 'fill', 'stroke', or 'preserve'
base_size: square icon size, or full viewBox geometry for preserve assets
"""
if not icon_path.exists():
return [], 'fill', 16
content = icon_path.read_text(encoding='utf-8')
if _is_preserve_color_asset(content):
geometry = _get_viewbox_geometry(content) or (0.0, 0.0, DEFAULT_ICON_BASE_SIZE, DEFAULT_ICON_BASE_SIZE)
elements = _extract_svg_body(content)
return elements, 'preserve', geometry
style = _detect_icon_style(content)
base_size = _get_viewbox_size(content) or 16
elements = _extract_shape_elements(content, target_color)
return elements, style, base_size
def parse_use_element(use_match: str) -> dict[str, str | float]:
"""
Parse attributes of a use element.
Args:
use_match: Complete string of the use element
Returns:
Attribute dictionary
"""
attrs: dict[str, str | float] = {}
# Extract data-icon
icon_match = re.search(r'data-icon="([^"]+)"', use_match)
if icon_match:
attrs['icon'] = icon_match.group(1)
# Extract numeric attributes
for attr in ['x', 'y', 'width', 'height']:
match = re.search(rf'{attr}="([^"]+)"', use_match)
if match:
attrs[attr] = float(match.group(1))
# Extract fill color
fill_match = re.search(r'fill="([^"]+)"', use_match)
if fill_match:
attrs['fill'] = fill_match.group(1)
# Stroke-style icons may be authored with natural SVG semantics:
# fill="none" stroke="#HEX". Keep accepting fill as the canonical color
# carrier, but preserve stroke so outline icons do not collapse to none.
stroke_match = re.search(r'stroke="([^"]+)"', use_match)
if stroke_match:
attrs['stroke'] = stroke_match.group(1)
# Live preview direct edits may write an absolute transform matrix back to
# the placeholder. Preserve it so the expanded icon matches the edited
# browser geometry instead of falling back to the original x/y placement.
transform_match = re.search(r'transform="([^"]+)"', use_match)
if transform_match:
attrs['transform'] = transform_match.group(1)
# Extract optional stroke-width override (stroke-style icons only).
# Tabler-outline ships at stroke-width=2; passing 1.5 reads thin, 3 reads bold.
stroke_width_match = re.search(r'stroke-width="([^"]+)"', use_match)
if stroke_width_match:
attrs['stroke-width'] = stroke_width_match.group(1)
return attrs
def resolve_icon_color(attrs: dict[str, str | float], style: str) -> str:
"""Resolve the caller-provided color for fill or stroke icon libraries."""
if style == 'preserve':
return 'preserve'
fill = str(attrs.get('fill', '')).strip()
stroke = str(attrs.get('stroke', '')).strip()
if style == 'stroke':
if fill and fill != 'none':
return fill
if stroke and stroke != 'none':
return stroke
return '#000000'
if fill:
return fill
if stroke and stroke != 'none':
return stroke
return '#000000'
def generate_icon_group(attrs: dict[str, str | float], elements: list[str], style: str, base_size: BaseGeometry) -> str:
"""
Generate the icon's element.
Args:
attrs: Attributes of the use element
elements: List of drawable SVG elements
style: 'fill', 'stroke', or 'preserve'
base_size: Icon's natural size, or full viewBox geometry for preserve assets
Returns:
Complete element string
"""
min_x, min_y, base_width, base_height = _base_geometry(base_size)
x = attrs.get('x', 0)
y = attrs.get('y', 0)
width = attrs.get('width', base_width)
height = attrs.get('height', base_height)
color = resolve_icon_color(attrs, style)
icon_name = attrs.get('icon', 'unknown')
scale_x = float(width) / base_width
scale_y = float(height) / base_height
if attrs.get('transform'):
# This transform is authoritative: the editor computes it from the
# expanded , so composing it with x/y would apply placement twice.
transform = str(attrs['transform'])
elif abs(scale_x - 1) < 1e-6 and abs(scale_y - 1) < 1e-6:
transform = f'translate({_format_number(x)}, {_format_number(y)})'
elif abs(scale_x - scale_y) < 1e-6:
transform = f'translate({_format_number(x)}, {_format_number(y)}) scale({_format_number(scale_x)})'
else:
transform = (
f'translate({_format_number(x)}, {_format_number(y)}) '
f'scale({_format_number(scale_x)}, {_format_number(scale_y)})'
)
elements_str = '\n '.join(elements)
if style == 'preserve':
if min_x or min_y:
inner_transform = f'translate({_format_number(-min_x)}, {_format_number(-min_y)})'
elements_str = f'\n {elements_str}\n '
return f'''
{elements_str}
'''
if style == 'stroke':
# Default to 2 — matches the source stroke-width baked into tabler-outline
# (and any other stroke library) so omitting the attribute reproduces
# pre-change visual output.
stroke_width = attrs.get('stroke-width', '2')
color_attrs = f'fill="none" stroke="{color}" stroke-width="{stroke_width}"'
else:
color_attrs = f'fill="{color}"'
return f'''
{elements_str}
'''
def process_svg_file(svg_path: Path, icons_dir: Path, dry_run: bool = False, verbose: bool = False, fallback_dir: Path | None = None) -> int:
"""
Process a single SVG file, replacing all icon placeholders.
Args:
svg_path: SVG file path
icons_dir: Icon directory path
dry_run: Whether to only preview without modifying
verbose: Whether to show detailed information
Returns:
Number of icons replaced
"""
if not svg_path.exists():
print(f"[ERROR] File not found: {svg_path}")
return 0
content = svg_path.read_text(encoding='utf-8')
# Match elements
use_pattern = r'