| 1 | #!/usr/bin/env python3 |
| 2 | """ |
| 3 | SVG Icon Embedding Tool |
| 4 | |
| 5 | Replaces icon placeholders in SVG files with actual icon code. |
| 6 | |
| 7 | Placeholder syntax (new SVGs must include a library prefix): |
| 8 | <use data-icon="chunk-filled/rocket" x="100" y="200" width="48" height="48" fill="#0076A8"/> |
| 9 | <use data-icon="tabler-filled/home" x="100" y="200" width="48" height="48" fill="#0076A8"/> |
| 10 | <use data-icon="tabler-outline/home" x="100" y="200" width="48" height="48" fill="#0076A8"/> |
| 11 | <use data-icon="tabler-outline/home" x="100" y="200" width="48" height="48" fill="#0076A8" stroke-width="3"/> |
| 12 | <use data-icon="imported/layered_slide_06_ill01"/> |
| 13 | |
| 14 | Legacy compatibility accepted by the resolver: |
| 15 | <use data-icon="rocket" .../> -> chunk-filled/rocket |
| 16 | <use data-icon="chunk/rocket" .../> -> chunk-filled/rocket |
| 17 | |
| 18 | Optional `stroke-width` (stroke-style libraries only — e.g. tabler-outline): |
| 19 | Default 2 (matches the source). Pass 1.5 for thin, 3 for bold. |
| 20 | Ignored on fill-style libraries. |
| 21 | |
| 22 | After replacement: |
| 23 | <g transform="translate(100, 200) scale(3)" fill="#0076A8"> |
| 24 | <path d="..."/> |
| 25 | </g> |
| 26 | |
| 27 | Icon libraries (subdirectories of templates/icons/): |
| 28 | chunk-filled/ - 640+ fill icons, 16x16 viewBox (use prefix: chunk-filled/name; legacy 'chunk/' also accepted) |
| 29 | tabler-filled/ - 1000+ fill icons, 24x24 viewBox (use prefix: tabler-filled/name) |
| 30 | tabler-outline/ - 5000+ stroke icons, 24x24 viewBox (use prefix: tabler-outline/name) |
| 31 | phosphor-duotone/ - 1200+ duotone icons, 256x256 viewBox (single color + 0.2-opacity backplate) |
| 32 | simple-icons/ - 3400+ brand logos, 24x24 viewBox (brand-inset library — used alongside the chosen primary library, NOT as a standalone library for generic icons) |
| 33 | imported/ - project-local extracted vector illustrations with data-icon-style="preserve-color"; preserve source colors and natural viewBox aspect ratio |
| 34 | |
| 35 | Usage: |
| 36 | python3 scripts/svg_finalize/embed_icons.py <svg_file> [svg_file2] ... |
| 37 | python3 scripts/svg_finalize/embed_icons.py svg_output/*.svg |
| 38 | |
| 39 | Options: |
| 40 | --icons-dir <path> Icon directory path (default: templates/icons/) |
| 41 | --dry-run Only show what would be replaced, without modifying files |
| 42 | --verbose Show detailed information |
| 43 | """ |
| 44 | |
| 45 | from __future__ import annotations |
| 46 | |
| 47 | import os |
| 48 | import re |
| 49 | import sys |
| 50 | import argparse |
| 51 | from pathlib import Path |
| 52 | from xml.etree import ElementTree as ET |
| 53 | |
| 54 | _SCRIPTS_DIR = Path(__file__).resolve().parents[1] |
| 55 | if str(_SCRIPTS_DIR) not in sys.path: |
| 56 | sys.path.insert(0, str(_SCRIPTS_DIR)) |
| 57 | |
| 58 | from console_encoding import configure_utf8_stdio # noqa: E402 |
| 59 | from svg_to_pptx.drawingml.utils import parse_project_geometry_length # noqa: E402 |
| 60 | |
| 61 | configure_utf8_stdio() |
| 62 | |
| 63 | |
| 64 | # Default icon directory |
| 65 | DEFAULT_ICONS_DIR = Path(__file__).parent.parent.parent / 'templates' / 'icons' |
| 66 | |
| 67 | # Icon base size per library |
| 68 | ICON_BASE_SIZES = { |
| 69 | 'chunk-filled': 16, |
| 70 | 'chunk': 16, # backward compat alias → chunk-filled/ |
| 71 | 'tabler-filled': 24, |
| 72 | 'tabler-outline': 24, |
| 73 | 'phosphor-duotone': 256, |
| 74 | 'simple-icons': 24, |
| 75 | } |
| 76 | _ICON_LIBRARY_ALIASES = {'chunk': 'chunk-filled'} |
| 77 | DEFAULT_ICON_BASE_SIZE = 24 |
| 78 | BaseGeometry = float | tuple[float, float, float, float] |
| 79 | |
| 80 | |
| 81 | def _get_viewbox_size(content: str) -> float: |
| 82 | """Extract the width from viewBox attribute (assumed square). Returns 0 if not found.""" |
| 83 | m = re.search(r'viewBox=["\']0 0 ([\d.]+)', content) |
| 84 | if m: |
| 85 | return float(m.group(1)) |
| 86 | return 0 |
| 87 | |
| 88 | |
| 89 | def _get_viewbox_geometry(content: str) -> tuple[float, float, float, float] | None: |
| 90 | """Extract full viewBox geometry as (min_x, min_y, width, height).""" |
| 91 | match = re.search(r'viewBox=["\']([^"\']+)["\']', content) |
| 92 | if not match: |
| 93 | return None |
| 94 | parts = re.split(r'[\s,]+', match.group(1).strip()) |
| 95 | if len(parts) < 4: |
| 96 | return None |
| 97 | try: |
| 98 | min_x, min_y, width, height = [float(part) for part in parts[:4]] |
| 99 | except ValueError: |
| 100 | return None |
| 101 | if width <= 0 or height <= 0: |
| 102 | return None |
| 103 | return min_x, min_y, width, height |
| 104 | |
| 105 | |
| 106 | def _format_number(value: object) -> str: |
| 107 | """Format SVG numeric values compactly without losing meaningful precision.""" |
| 108 | if isinstance(value, float): |
| 109 | return f'{value:g}' |
| 110 | return str(value) |
| 111 | |
| 112 | |
| 113 | def _base_geometry(base_size: BaseGeometry) -> tuple[float, float, float, float]: |
| 114 | """Normalize legacy square icon size and full viewBox geometry.""" |
| 115 | if isinstance(base_size, tuple): |
| 116 | return base_size |
| 117 | return 0.0, 0.0, float(base_size), float(base_size) |
| 118 | |
| 119 | |
| 120 | def _is_preserve_color_asset(content: str) -> bool: |
| 121 | """Project illustrations are vector assets, not recolorable monochrome icons. |
| 122 | |
| 123 | The `data-icon-style="preserve-color"` marker is stamped by |
| 124 | extract_svg_assets.py and is the single source of truth — hand-authored |
| 125 | multi-color assets must carry it to keep their colors and aspect ratio. |
| 126 | """ |
| 127 | return 'data-icon-style="preserve-color"' in content |
| 128 | |
| 129 | |
| 130 | def _detect_icon_style(content: str) -> str: |
| 131 | """Detect whether an icon is fill-based or stroke-based.""" |
| 132 | # stroke="currentColor" with fill="none" → stroke style |
| 133 | if 'stroke="currentColor"' in content and 'fill="none"' in content: |
| 134 | return 'stroke' |
| 135 | return 'fill' |
| 136 | |
| 137 | |
| 138 | def _extract_svg_body(content: str) -> list[str]: |
| 139 | """Return the root SVG body for preserve-color assets without editing attrs.""" |
| 140 | match = re.search(r'<svg\b[^>]*>(.*)</svg>\s*$', content, re.DOTALL) |
| 141 | if not match: |
| 142 | return [] |
| 143 | body = match.group(1).strip() |
| 144 | return [body] if body else [] |
| 145 | |
| 146 | |
| 147 | def _extract_shape_elements(content: str, color: str) -> list[str]: |
| 148 | """ |
| 149 | Extract all drawable shape elements from an icon SVG, replacing |
| 150 | fill/stroke color references (currentColor or #xxxxxx) with the target color. |
| 151 | |
| 152 | Supports: <path>, <circle>, <rect>, <line>, <polyline>, <polygon>, <ellipse> |
| 153 | """ |
| 154 | shape_tags = ('path', 'circle', 'rect', 'line', 'polyline', 'polygon', 'ellipse') |
| 155 | pattern = r'<(' + '|'.join(shape_tags) + r')(\s[^>]*)?(?:/>|></\1>)' |
| 156 | matches = re.findall(pattern, content, re.DOTALL) |
| 157 | |
| 158 | elements = [] |
| 159 | for tag, attrs in matches: |
| 160 | # Remove standalone fill/stroke color attrs so outer <g> controls color. |
| 161 | # Also strip stroke-width so the outer <g> can override it (otherwise the |
| 162 | # icon's source stroke-width="2" would shadow any caller-specified value). |
| 163 | attrs_clean = re.sub(r'\s*fill="(?:currentColor|#[0-9a-fA-F]{3,6}|none)"', '', attrs) |
| 164 | attrs_clean = re.sub(r'\s*stroke="(?:currentColor|#[0-9a-fA-F]{3,6}|none)"', '', attrs_clean) |
| 165 | attrs_clean = re.sub(r'\s*stroke-width="[^"]*"', '', attrs_clean) |
| 166 | elements.append(f'<{tag}{attrs_clean}/>') |
| 167 | |
| 168 | return elements |
| 169 | |
| 170 | |
| 171 | def _resolve_in_dir(icon_name: str, icons_dir: Path) -> tuple[Path, float]: |
| 172 | """Resolve `icon_name` against a single icons dir (no fallback).""" |
| 173 | if '/' in icon_name: |
| 174 | lib, name = icon_name.split('/', 1) |
| 175 | lib = _ICON_LIBRARY_ALIASES.get(lib, lib) # resolve aliases |
| 176 | icon_path = icons_dir / lib / f'{name}.svg' |
| 177 | base_size = ICON_BASE_SIZES.get(lib, 24) |
| 178 | else: |
| 179 | # Backward compatibility: un-prefixed names fall back to legacy chunk-filled/ library |
| 180 | icon_path = icons_dir / 'chunk-filled' / f'{icon_name}.svg' |
| 181 | base_size = 16 |
| 182 | if not icon_path.exists(): |
| 183 | icon_path = icons_dir / f'{icon_name}.svg' # legacy flat layout |
| 184 | base_size = 16 |
| 185 | |
| 186 | return icon_path, base_size |
| 187 | |
| 188 | |
| 189 | def _casefold_icon_name_in_dir(icon_name: str, icons_dir: Path) -> str | None: |
| 190 | """Return the exact on-disk identifier when only casing differs.""" |
| 191 | if not icons_dir.is_dir(): |
| 192 | return None |
| 193 | |
| 194 | search_dirs: list[Path] = [] |
| 195 | expected_name = icon_name |
| 196 | if '/' in icon_name: |
| 197 | raw_lib, expected_name = icon_name.split('/', 1) |
| 198 | requested_lib = _ICON_LIBRARY_ALIASES.get(raw_lib.casefold(), raw_lib) |
| 199 | library_dir = icons_dir / requested_lib |
| 200 | if not library_dir.is_dir(): |
| 201 | library_dir = next( |
| 202 | ( |
| 203 | path for path in icons_dir.iterdir() |
| 204 | if path.is_dir() |
| 205 | and path.name.casefold() == requested_lib.casefold() |
| 206 | ), |
| 207 | library_dir, |
| 208 | ) |
| 209 | search_dirs.append(library_dir) |
| 210 | else: |
| 211 | search_dirs.extend((icons_dir / 'chunk-filled', icons_dir)) |
| 212 | |
| 213 | expected_filename = f'{expected_name}.svg'.casefold() |
| 214 | for search_dir in search_dirs: |
| 215 | if not search_dir.is_dir(): |
| 216 | continue |
| 217 | matches = sorted( |
| 218 | path for path in search_dir.iterdir() |
| 219 | if path.is_file() |
| 220 | and path.suffix.casefold() == '.svg' |
| 221 | and path.name.casefold() == expected_filename |
| 222 | ) |
| 223 | if len(matches) != 1: |
| 224 | continue |
| 225 | relative = matches[0].relative_to(icons_dir).with_suffix('') |
| 226 | return relative.as_posix() |
| 227 | return None |
| 228 | |
| 229 | |
| 230 | def suggest_icon_name( |
| 231 | icon_name: str, |
| 232 | icons_dir: Path, |
| 233 | fallback_dir: Path | None = None, |
| 234 | ) -> str | None: |
| 235 | """Suggest an exact project-first icon identifier without auto-correcting it.""" |
| 236 | suggestion = _casefold_icon_name_in_dir(icon_name, icons_dir) |
| 237 | if suggestion is None and fallback_dir is not None: |
| 238 | suggestion = _casefold_icon_name_in_dir(icon_name, fallback_dir) |
| 239 | return suggestion |
| 240 | |
| 241 | |
| 242 | def resolve_icon_path(icon_name: str, icons_dir: Path, fallback_dir: Path | None = None) -> tuple[Path, float]: |
| 243 | """ |
| 244 | Resolve icon name to file path and base size, e.g. "chunk-filled/home" → |
| 245 | icons_dir/chunk-filled/home.svg. "chunk/" is a backward-compat alias; an |
| 246 | un-prefixed name falls back to chunk-filled/ then a legacy flat layout. |
| 247 | |
| 248 | Resolution is project-first: if the icon is absent under ``icons_dir`` and a |
| 249 | ``fallback_dir`` (the global library) is given, the fallback's path is |
| 250 | returned instead. Returns (path, base_size); the path may not exist when |
| 251 | neither dir has the icon. |
| 252 | """ |
| 253 | icon_path, base_size = _resolve_in_dir(icon_name, icons_dir) |
| 254 | if fallback_dir is not None and not icon_path.exists(): |
| 255 | fb_path, fb_size = _resolve_in_dir(icon_name, fallback_dir) |
| 256 | if fb_path.exists(): |
| 257 | return fb_path, fb_size |
| 258 | return icon_path, base_size |
| 259 | |
| 260 | |
| 261 | def extract_paths_from_icon(icon_path: Path, target_color: str = '#000000') -> tuple[list[str], str, BaseGeometry]: |
| 262 | """ |
| 263 | Extract drawable elements from an icon SVG file. |
| 264 | |
| 265 | Returns: |
| 266 | (elements, style, base_size) |
| 267 | style: 'fill', 'stroke', or 'preserve' |
| 268 | base_size: square icon size, or full viewBox geometry for preserve assets |
| 269 | """ |
| 270 | if not icon_path.exists(): |
| 271 | return [], 'fill', 16 |
| 272 | |
| 273 | content = icon_path.read_text(encoding='utf-8') |
| 274 | if _is_preserve_color_asset(content): |
| 275 | geometry = _get_viewbox_geometry(content) or (0.0, 0.0, DEFAULT_ICON_BASE_SIZE, DEFAULT_ICON_BASE_SIZE) |
| 276 | elements = _extract_svg_body(content) |
| 277 | return elements, 'preserve', geometry |
| 278 | |
| 279 | style = _detect_icon_style(content) |
| 280 | base_size = _get_viewbox_size(content) or 16 |
| 281 | elements = _extract_shape_elements(content, target_color) |
| 282 | return elements, style, base_size |
| 283 | |
| 284 | |
| 285 | def _attr_value(tag_text: str, attr: str) -> str | None: |
| 286 | """Return an attribute value from a raw tag, accepting either quote style.""" |
| 287 | match = re.search( |
| 288 | rf'\b{re.escape(attr)}\s*=\s*(["\'])(.*?)\1', |
| 289 | tag_text, |
| 290 | re.DOTALL, |
| 291 | ) |
| 292 | return match.group(2) if match else None |
| 293 | |
| 294 | |
| 295 | def parse_use_element(use_match: str) -> dict[str, str | float]: |
| 296 | """ |
| 297 | Parse attributes of a use element. |
| 298 | |
| 299 | Args: |
| 300 | use_match: Complete string of the use element |
| 301 | |
| 302 | Returns: |
| 303 | Attribute dictionary |
| 304 | """ |
| 305 | attrs: dict[str, str | float] = {} |
| 306 | |
| 307 | # Extract data-icon |
| 308 | icon_value = _attr_value(use_match, 'data-icon') |
| 309 | if icon_value: |
| 310 | attrs['icon'] = icon_value |
| 311 | |
| 312 | # Extract numeric attributes |
| 313 | for attr in ['x', 'y', 'width', 'height']: |
| 314 | value = _attr_value(use_match, attr) |
| 315 | if value is not None: |
| 316 | attrs[attr] = parse_project_geometry_length(value, attr) |
| 317 | |
| 318 | # Extract fill color |
| 319 | fill_value = _attr_value(use_match, 'fill') |
| 320 | if fill_value is not None: |
| 321 | attrs['fill'] = fill_value |
| 322 | |
| 323 | # Stroke-style icons may be authored with natural SVG semantics: |
| 324 | # fill="none" stroke="#HEX". Keep accepting fill as the canonical color |
| 325 | # carrier, but preserve stroke so outline icons do not collapse to none. |
| 326 | stroke_value = _attr_value(use_match, 'stroke') |
| 327 | if stroke_value is not None: |
| 328 | attrs['stroke'] = stroke_value |
| 329 | |
| 330 | # Live preview direct edits may write an absolute transform matrix back to |
| 331 | # the placeholder. Preserve it so the expanded icon matches the edited |
| 332 | # browser geometry instead of falling back to the original x/y placement. |
| 333 | transform_value = _attr_value(use_match, 'transform') |
| 334 | if transform_value is not None: |
| 335 | attrs['transform'] = transform_value |
| 336 | |
| 337 | # Extract optional stroke-width override (stroke-style icons only). |
| 338 | # Tabler-outline ships at stroke-width=2; passing 1.5 reads thin, 3 reads bold. |
| 339 | stroke_width_value = _attr_value(use_match, 'stroke-width') |
| 340 | if stroke_width_value is not None: |
| 341 | attrs['stroke-width'] = stroke_width_value |
| 342 | |
| 343 | return attrs |
| 344 | |
| 345 | |
| 346 | def resolve_icon_color(attrs: dict[str, str | float], style: str) -> str: |
| 347 | """Resolve the caller-provided color for fill or stroke icon libraries.""" |
| 348 | if style == 'preserve': |
| 349 | return 'preserve' |
| 350 | |
| 351 | fill = str(attrs.get('fill', '')).strip() |
| 352 | stroke = str(attrs.get('stroke', '')).strip() |
| 353 | |
| 354 | if style == 'stroke': |
| 355 | if fill and fill != 'none': |
| 356 | return fill |
| 357 | if stroke and stroke != 'none': |
| 358 | return stroke |
| 359 | return '#000000' |
| 360 | |
| 361 | if fill: |
| 362 | return fill |
| 363 | if stroke and stroke != 'none': |
| 364 | return stroke |
| 365 | return '#000000' |
| 366 | |
| 367 | |
| 368 | def generate_icon_group(attrs: dict[str, str | float], elements: list[str], style: str, base_size: BaseGeometry) -> str: |
| 369 | """ |
| 370 | Generate the icon's <g> element. |
| 371 | |
| 372 | Args: |
| 373 | attrs: Attributes of the use element |
| 374 | elements: List of drawable SVG elements |
| 375 | style: 'fill', 'stroke', or 'preserve' |
| 376 | base_size: Icon's natural size, or full viewBox geometry for preserve assets |
| 377 | |
| 378 | Returns: |
| 379 | Complete <g> element string |
| 380 | """ |
| 381 | min_x, min_y, base_width, base_height = _base_geometry(base_size) |
| 382 | x = attrs.get('x', 0) |
| 383 | y = attrs.get('y', 0) |
| 384 | width = attrs.get('width', base_width) |
| 385 | height = attrs.get('height', base_height) |
| 386 | color = resolve_icon_color(attrs, style) |
| 387 | icon_name = attrs.get('icon', 'unknown') |
| 388 | |
| 389 | scale_x = float(width) / base_width |
| 390 | scale_y = float(height) / base_height |
| 391 | |
| 392 | if attrs.get('transform'): |
| 393 | # This transform is authoritative: the editor computes it from the |
| 394 | # expanded <g>, so composing it with x/y would apply placement twice. |
| 395 | transform = str(attrs['transform']) |
| 396 | elif abs(scale_x - 1) < 1e-6 and abs(scale_y - 1) < 1e-6: |
| 397 | transform = f'translate({_format_number(x)}, {_format_number(y)})' |
| 398 | elif abs(scale_x - scale_y) < 1e-6: |
| 399 | transform = f'translate({_format_number(x)}, {_format_number(y)}) scale({_format_number(scale_x)})' |
| 400 | else: |
| 401 | transform = ( |
| 402 | f'translate({_format_number(x)}, {_format_number(y)}) ' |
| 403 | f'scale({_format_number(scale_x)}, {_format_number(scale_y)})' |
| 404 | ) |
| 405 | |
| 406 | elements_str = '\n '.join(elements) |
| 407 | |
| 408 | if style == 'preserve': |
| 409 | if min_x or min_y: |
| 410 | inner_transform = f'translate({_format_number(-min_x)}, {_format_number(-min_y)})' |
| 411 | elements_str = f'<g transform="{inner_transform}">\n {elements_str}\n </g>' |
| 412 | return f'''<!-- icon: {icon_name} --> |
| 413 | <g transform="{transform}"> |
| 414 | {elements_str} |
| 415 | </g>''' |
| 416 | |
| 417 | if style == 'stroke': |
| 418 | # Default to 2 — matches the source stroke-width baked into tabler-outline |
| 419 | # (and any other stroke library) so omitting the attribute reproduces |
| 420 | # pre-change visual output. |
| 421 | stroke_width = attrs.get('stroke-width', '2') |
| 422 | color_attrs = f'fill="none" stroke="{color}" stroke-width="{stroke_width}"' |
| 423 | else: |
| 424 | color_attrs = f'fill="{color}"' |
| 425 | |
| 426 | return f'''<!-- icon: {icon_name} --> |
| 427 | <g transform="{transform}" {color_attrs}> |
| 428 | {elements_str} |
| 429 | </g>''' |
| 430 | |
| 431 | |
| 432 | def process_svg_file(svg_path: Path, icons_dir: Path, dry_run: bool = False, verbose: bool = False, fallback_dir: Path | None = None) -> int: |
| 433 | """ |
| 434 | Process a single SVG file, replacing all icon placeholders. |
| 435 | |
| 436 | Args: |
| 437 | svg_path: SVG file path |
| 438 | icons_dir: Icon directory path |
| 439 | dry_run: Whether to only preview without modifying |
| 440 | verbose: Whether to show detailed information |
| 441 | |
| 442 | Returns: |
| 443 | Number of icons replaced |
| 444 | """ |
| 445 | if not svg_path.exists(): |
| 446 | print(f"[ERROR] File not found: {svg_path}") |
| 447 | return 0 |
| 448 | |
| 449 | content = svg_path.read_text(encoding='utf-8') |
| 450 | |
| 451 | # Match self-closing <use data-icon="..."/> placeholders. Attribute |
| 452 | # parsing below accepts both single and double quotes. |
| 453 | use_pattern = r'<use\b(?=[^>]*\bdata-icon\s*=)[^>]*/>' |
| 454 | matches = list(re.finditer(use_pattern, content, re.IGNORECASE | re.DOTALL)) |
| 455 | |
| 456 | if not matches: |
| 457 | if verbose: |
| 458 | print(f"[SKIP] No icon placeholders: {svg_path}") |
| 459 | return 0 |
| 460 | |
| 461 | replaced_count = 0 |
| 462 | new_content = content |
| 463 | |
| 464 | # Replace from back to front to avoid position offset |
| 465 | for match in reversed(matches): |
| 466 | use_str = match.group(0) |
| 467 | attrs = parse_use_element(use_str) |
| 468 | |
| 469 | icon_name = attrs.get('icon') |
| 470 | if not icon_name: |
| 471 | continue |
| 472 | |
| 473 | icon_path, _ = resolve_icon_path(str(icon_name), icons_dir, fallback_dir) |
| 474 | if not icon_path.exists(): |
| 475 | suggestion = suggest_icon_name(str(icon_name), icons_dir, fallback_dir) |
| 476 | hint = ( |
| 477 | f"; identifiers are case-sensitive; use '{suggestion}'" |
| 478 | if suggestion else "" |
| 479 | ) |
| 480 | print( |
| 481 | f"[WARN] Icon not found: {icon_name}{hint} " |
| 482 | f"(in {svg_path.name})" |
| 483 | ) |
| 484 | continue |
| 485 | |
| 486 | elements, style, base_size = extract_paths_from_icon(icon_path) |
| 487 | color = resolve_icon_color(attrs, style) |
| 488 | if not elements: |
| 489 | print( |
| 490 | f"[WARN] Icon has no embeddable shapes: {icon_name} " |
| 491 | f"(in {svg_path.name})" |
| 492 | ) |
| 493 | continue |
| 494 | |
| 495 | replacement = generate_icon_group(attrs, elements, style, base_size) |
| 496 | |
| 497 | if verbose or dry_run: |
| 498 | print(f" [*] {icon_name}: x={attrs.get('x', 0)}, y={attrs.get('y', 0)}, " |
| 499 | f"size={attrs.get('width', base_size)}, fill={color}, style={style}") |
| 500 | |
| 501 | new_content = new_content[:match.start()] + replacement + new_content[match.end():] |
| 502 | replaced_count += 1 |
| 503 | |
| 504 | if not dry_run and replaced_count > 0: |
| 505 | svg_path.write_text(new_content, encoding='utf-8') |
| 506 | |
| 507 | status = "[PREVIEW]" if dry_run else "[OK]" |
| 508 | print(f"{status} {svg_path.name} ({replaced_count} icons)") |
| 509 | |
| 510 | return replaced_count |
| 511 | |
| 512 | |
| 513 | def main() -> None: |
| 514 | """Run the CLI entry point.""" |
| 515 | parser = argparse.ArgumentParser( |
| 516 | description='Replace icon placeholders in SVG files with actual icon code', |
| 517 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 518 | epilog=''' |
| 519 | Examples: |
| 520 | python3 scripts/svg_finalize/embed_icons.py svg_output/01_cover.svg |
| 521 | python3 scripts/svg_finalize/embed_icons.py svg_output/*.svg |
| 522 | python3 scripts/svg_finalize/embed_icons.py --dry-run svg_output/*.svg |
| 523 | python3 scripts/svg_finalize/embed_icons.py --icons-dir my_icons/ output.svg |
| 524 | ''' |
| 525 | ) |
| 526 | |
| 527 | parser.add_argument('files', nargs='+', help='SVG files to process') |
| 528 | parser.add_argument('--icons-dir', type=Path, default=DEFAULT_ICONS_DIR, |
| 529 | help=f'Icon directory path (default: {DEFAULT_ICONS_DIR})') |
| 530 | parser.add_argument('--dry-run', action='store_true', |
| 531 | help='Only show what would be replaced, without modifying files') |
| 532 | parser.add_argument('--verbose', '-v', action='store_true', |
| 533 | help='Show detailed information') |
| 534 | |
| 535 | args = parser.parse_args() |
| 536 | |
| 537 | # Validate icon directory |
| 538 | if not args.icons_dir.exists(): |
| 539 | print(f"[ERROR] Icon directory not found: {args.icons_dir}") |
| 540 | sys.exit(1) |
| 541 | |
| 542 | print(f"[DIR] Icon directory: {args.icons_dir}") |
| 543 | if args.dry_run: |
| 544 | print("[PREVIEW] Preview mode (no files will be modified)") |
| 545 | print() |
| 546 | |
| 547 | total_replaced = 0 |
| 548 | total_files = 0 |
| 549 | |
| 550 | for file_pattern in args.files: |
| 551 | svg_path = Path(file_pattern) |
| 552 | if svg_path.exists(): |
| 553 | count = process_svg_file(svg_path, args.icons_dir, args.dry_run, args.verbose) |
| 554 | total_replaced += count |
| 555 | if count > 0: |
| 556 | total_files += 1 |
| 557 | |
| 558 | print() |
| 559 | print(f"[Summary] Total: {total_files} file(s), {total_replaced} icon(s)" + |
| 560 | (" (preview)" if args.dry_run else " replaced")) |
| 561 | |
| 562 | |
| 563 | if __name__ == '__main__': |
| 564 | main() |
| 565 |