返回 ppt-master
embed_icons.py
根目录 / skills / ppt-master / scripts / svg_finalize / embed_icons.py
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
565 lines PYTHON