| 1 | """In-memory expansion of project icons and static local ``<use>`` elements. |
| 2 | |
| 3 | The icon placeholder ``<use data-icon="...">`` is a project-internal SVG |
| 4 | extension; standard renderers (browsers, PowerPoint's SVG parser) and our |
| 5 | own DrawingML dispatcher do not understand it. ``finalize_svg`` already |
| 6 | expands it on disk into ``svg_final/``; this module provides the same |
| 7 | expansion in memory so ``svg_to_pptx`` can consume ``svg_output/`` directly |
| 8 | without first running the on-disk finalize step. |
| 9 | |
| 10 | Public API: |
| 11 | expand_use_data_icons(root, icons_dir, fallback_dir=None) -> int |
| 12 | Walk the SVG element tree, replace every ``<use data-icon="...">`` |
| 13 | with its expanded ``<g>`` group of primitive shapes, and return |
| 14 | the number of replacements made. |
| 15 | expand_local_use_references(root) -> int |
| 16 | Materialize same-document ``href="#id"`` references, including |
| 17 | ``<symbol>`` viewBox mapping, and return the number of instances. |
| 18 | expand_local_use_references_in_file(svg_path) -> int |
| 19 | Apply the same expansion to an SVG file in place. |
| 20 | |
| 21 | The heavy lifting (icon resolution, color application, scaling) is |
| 22 | delegated to ``svg_finalize.embed_icons`` so the two pipelines stay |
| 23 | behaviourally aligned. |
| 24 | """ |
| 25 | |
| 26 | from __future__ import annotations |
| 27 | |
| 28 | import copy |
| 29 | import math |
| 30 | import re |
| 31 | import sys |
| 32 | from pathlib import Path |
| 33 | from xml.etree import ElementTree as ET |
| 34 | |
| 35 | |
| 36 | SVG_NS = 'http://www.w3.org/2000/svg' |
| 37 | XLINK_NS = 'http://www.w3.org/1999/xlink' |
| 38 | |
| 39 | _LOCAL_HREF_RE = re.compile(r'^#([^#\s]+)$') |
| 40 | _LENGTH_RE = re.compile( |
| 41 | r'^\s*([-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?)\s*(?:px)?\s*$' |
| 42 | ) |
| 43 | _VIEWBOX_SPLIT_RE = re.compile(r'[\s,]+') |
| 44 | _URL_REF_RE = re.compile(r'''url\(#([^#\s()'"\\]+)\)''') |
| 45 | _URL_FUNCTION_RE = re.compile(r'\burl\s*\([^)]*\)', re.IGNORECASE) |
| 46 | _URL_FUNCTION_START_RE = re.compile(r'\burl\s*\(', re.IGNORECASE) |
| 47 | _MAX_LOCAL_USE_DEPTH = 64 |
| 48 | _MAX_LOCAL_USE_INSTANCES = 10_000 |
| 49 | _NON_REUSABLE_METADATA_PREFIXES = ( |
| 50 | 'data-pptx-authoring', |
| 51 | 'data-pptx-object', |
| 52 | 'data-pptx-prst', |
| 53 | 'data-pptx-frame', |
| 54 | 'data-pptx-av-', |
| 55 | 'data-pptx-layer', |
| 56 | 'data-pptx-replace-with', |
| 57 | 'data-pptx-replacement-', |
| 58 | 'data-pptx-import-source', |
| 59 | 'data-pptx-fallback-', |
| 60 | 'data-pptx-native', |
| 61 | 'data-pptx-visual-status', |
| 62 | 'data-pptx-route-status', |
| 63 | 'data-pptx-placeholder', |
| 64 | ) |
| 65 | _REFERENCE_TAGS = frozenset({ |
| 66 | 'symbol', 'g', 'use', |
| 67 | 'rect', 'circle', 'ellipse', 'line', 'path', 'polygon', 'polyline', |
| 68 | 'text', 'image', |
| 69 | }) |
| 70 | |
| 71 | |
| 72 | class UseExpansionError(ValueError): |
| 73 | """Raised when a static local ``<use>`` cannot be expanded safely.""" |
| 74 | |
| 75 | |
| 76 | def _local_tag(elem: ET.Element) -> str: |
| 77 | """Return an ElementTree node's local tag name.""" |
| 78 | return elem.tag.rsplit('}', 1)[-1] if '}' in str(elem.tag) else str(elem.tag) |
| 79 | |
| 80 | |
| 81 | def _qualified_tag(elem: ET.Element, local: str) -> str: |
| 82 | """Return ``local`` in the same namespace as ``elem``.""" |
| 83 | tag = str(elem.tag) |
| 84 | if tag.startswith('{') and '}' in tag: |
| 85 | return f'{tag.split("}", 1)[0]}}}{local}' |
| 86 | return local |
| 87 | |
| 88 | |
| 89 | def _fmt_number(value: float) -> str: |
| 90 | """Format a finite SVG transform number without negative zero.""" |
| 91 | if abs(value) < 1e-12: |
| 92 | value = 0.0 |
| 93 | return f'{value:.12g}' |
| 94 | |
| 95 | |
| 96 | def _numeric_length( |
| 97 | elem: ET.Element, |
| 98 | name: str, |
| 99 | default: float | None = None, |
| 100 | ) -> float | None: |
| 101 | """Parse a unitless/px local-use geometry value.""" |
| 102 | raw = elem.get(name) |
| 103 | if raw is None: |
| 104 | return default |
| 105 | match = _LENGTH_RE.fullmatch(raw) |
| 106 | if match is None: |
| 107 | raise UseExpansionError( |
| 108 | f'<use> {name} must be a finite unitless or px value, got {raw!r}' |
| 109 | ) |
| 110 | value = float(match.group(1)) |
| 111 | if not math.isfinite(value): |
| 112 | raise UseExpansionError(f'<use> {name} must be finite, got {raw!r}') |
| 113 | return value |
| 114 | |
| 115 | |
| 116 | def _parse_viewbox(symbol: ET.Element) -> tuple[float, float, float, float]: |
| 117 | """Parse a positive four-number symbol viewBox.""" |
| 118 | raw = symbol.get('viewBox', '') |
| 119 | parts = [part for part in _VIEWBOX_SPLIT_RE.split(raw.strip()) if part] |
| 120 | if len(parts) != 4: |
| 121 | raise UseExpansionError( |
| 122 | '<symbol> referenced by <use> must define a four-number viewBox' |
| 123 | ) |
| 124 | try: |
| 125 | values = tuple(float(part) for part in parts) |
| 126 | except ValueError as exc: |
| 127 | raise UseExpansionError( |
| 128 | f'<symbol> viewBox contains a non-numeric value: {raw!r}' |
| 129 | ) from exc |
| 130 | if not all(math.isfinite(value) for value in values): |
| 131 | raise UseExpansionError(f'<symbol> viewBox must be finite, got {raw!r}') |
| 132 | min_x, min_y, width, height = values |
| 133 | if width <= 0 or height <= 0: |
| 134 | raise UseExpansionError( |
| 135 | f'<symbol> viewBox width/height must be positive, got {raw!r}' |
| 136 | ) |
| 137 | return min_x, min_y, width, height |
| 138 | |
| 139 | |
| 140 | def _symbol_viewport_transform(symbol: ET.Element, use_elem: ET.Element) -> str: |
| 141 | """Map one symbol viewBox into the use element's explicit viewport.""" |
| 142 | for attr in ('refX', 'refY'): |
| 143 | if symbol.get(attr) is not None: |
| 144 | raise UseExpansionError( |
| 145 | f'<symbol> {attr} is not supported by local <use> expansion' |
| 146 | ) |
| 147 | |
| 148 | min_x, min_y, view_width, view_height = _parse_viewbox(symbol) |
| 149 | width = _numeric_length(use_elem, 'width') |
| 150 | height = _numeric_length(use_elem, 'height') |
| 151 | if width is None or height is None or width <= 0 or height <= 0: |
| 152 | raise UseExpansionError( |
| 153 | '<use> referencing <symbol> requires positive numeric width and height' |
| 154 | ) |
| 155 | |
| 156 | raw_aspect = symbol.get('preserveAspectRatio', 'xMidYMid meet').strip() |
| 157 | parts = raw_aspect.split() |
| 158 | if parts and parts[0] == 'defer': |
| 159 | parts.pop(0) |
| 160 | align = parts[0] if parts else 'xMidYMid' |
| 161 | mode = parts[1] if len(parts) > 1 else 'meet' |
| 162 | if len(parts) > 2 or mode not in {'meet', 'slice'}: |
| 163 | raise UseExpansionError( |
| 164 | f'Unsupported symbol preserveAspectRatio: {raw_aspect!r}' |
| 165 | ) |
| 166 | |
| 167 | if align == 'none': |
| 168 | if len(parts) > 1: |
| 169 | raise UseExpansionError( |
| 170 | f'Unsupported symbol preserveAspectRatio: {raw_aspect!r}' |
| 171 | ) |
| 172 | scale_x = width / view_width |
| 173 | scale_y = height / view_height |
| 174 | translate_x = -min_x * scale_x |
| 175 | translate_y = -min_y * scale_y |
| 176 | else: |
| 177 | alignments = { |
| 178 | 'xMinYMin': (0.0, 0.0), 'xMidYMin': (0.5, 0.0), 'xMaxYMin': (1.0, 0.0), |
| 179 | 'xMinYMid': (0.0, 0.5), 'xMidYMid': (0.5, 0.5), 'xMaxYMid': (1.0, 0.5), |
| 180 | 'xMinYMax': (0.0, 1.0), 'xMidYMax': (0.5, 1.0), 'xMaxYMax': (1.0, 1.0), |
| 181 | } |
| 182 | if align not in alignments: |
| 183 | raise UseExpansionError( |
| 184 | f'Unsupported symbol preserveAspectRatio: {raw_aspect!r}' |
| 185 | ) |
| 186 | if mode == 'slice': |
| 187 | raise UseExpansionError( |
| 188 | 'symbol preserveAspectRatio="... slice" requires viewport clipping, ' |
| 189 | 'which local <use> expansion does not approximate' |
| 190 | ) |
| 191 | scale = min(width / view_width, height / view_height) |
| 192 | scale_x = scale_y = scale |
| 193 | align_x, align_y = alignments[align] |
| 194 | translate_x = (width - view_width * scale) * align_x - min_x * scale |
| 195 | translate_y = (height - view_height * scale) * align_y - min_y * scale |
| 196 | |
| 197 | return ( |
| 198 | f'matrix({_fmt_number(scale_x)} 0 0 {_fmt_number(scale_y)} ' |
| 199 | f'{_fmt_number(translate_x)} {_fmt_number(translate_y)})' |
| 200 | ) |
| 201 | |
| 202 | |
| 203 | class _LocalUseExpander: |
| 204 | """Materialize static same-document SVG use references.""" |
| 205 | |
| 206 | def __init__(self, root: ET.Element): |
| 207 | self.root = root |
| 208 | self.targets: dict[str, ET.Element] = {} |
| 209 | self.duplicate_ids: set[str] = set() |
| 210 | self.used_ids: set[str] = set() |
| 211 | self.instance_index = 0 |
| 212 | self.instances_started = 0 |
| 213 | self.expanded = 0 |
| 214 | for elem in root.iter(): |
| 215 | elem_id = elem.get('id') |
| 216 | if not elem_id: |
| 217 | continue |
| 218 | self.used_ids.add(elem_id) |
| 219 | if elem_id in self.targets: |
| 220 | self.duplicate_ids.add(elem_id) |
| 221 | else: |
| 222 | self.targets[elem_id] = elem |
| 223 | |
| 224 | def expand(self) -> int: |
| 225 | """Expand every visible non-data-icon use node in the document.""" |
| 226 | self._expand_children(self.root, ()) |
| 227 | return self.expanded |
| 228 | |
| 229 | def _expand_children(self, parent: ET.Element, stack: tuple[str, ...]) -> None: |
| 230 | for index, child in enumerate(list(parent)): |
| 231 | if _local_tag(child) == 'defs': |
| 232 | continue |
| 233 | if _local_tag(child) == 'use' and not child.get('data-icon'): |
| 234 | replacement = self._materialize_use(child, stack) |
| 235 | parent.remove(child) |
| 236 | parent.insert(index, replacement) |
| 237 | continue |
| 238 | self._expand_children(child, stack) |
| 239 | |
| 240 | def _materialize_use( |
| 241 | self, |
| 242 | use_elem: ET.Element, |
| 243 | stack: tuple[str, ...], |
| 244 | ) -> ET.Element: |
| 245 | self.instances_started += 1 |
| 246 | if self.instances_started > _MAX_LOCAL_USE_INSTANCES: |
| 247 | raise UseExpansionError( |
| 248 | 'Local <use> expansion exceeds the 10000-instance safety limit' |
| 249 | ) |
| 250 | |
| 251 | href = use_elem.get('href') |
| 252 | xlink_href = use_elem.get(f'{{{XLINK_NS}}}href') |
| 253 | if href is not None and xlink_href is not None: |
| 254 | if href != xlink_href: |
| 255 | raise UseExpansionError( |
| 256 | 'Conflicting href and xlink:href values on local <use>' |
| 257 | ) |
| 258 | if href is None: |
| 259 | href = xlink_href |
| 260 | match = _LOCAL_HREF_RE.fullmatch(href or '') |
| 261 | if match is None: |
| 262 | raise UseExpansionError( |
| 263 | '<use> must reference a same-document fragment with href="#id"; ' |
| 264 | f'got {href!r}' |
| 265 | ) |
| 266 | ref_id = match.group(1) |
| 267 | if len(stack) >= _MAX_LOCAL_USE_DEPTH: |
| 268 | chain = ' -> '.join((*stack, ref_id)) |
| 269 | raise UseExpansionError( |
| 270 | f'Local <use> expansion exceeds the 64-reference depth limit: {chain}' |
| 271 | ) |
| 272 | if ref_id in self.duplicate_ids: |
| 273 | raise UseExpansionError( |
| 274 | f'<use href="#{ref_id}"> is ambiguous because the id is duplicated' |
| 275 | ) |
| 276 | use_id = use_elem.get('id') |
| 277 | if use_id and use_id in self.duplicate_ids: |
| 278 | raise UseExpansionError( |
| 279 | f'Local <use> instance id {use_id!r} is duplicated in this SVG' |
| 280 | ) |
| 281 | target = self.targets.get(ref_id) |
| 282 | if target is None: |
| 283 | raise UseExpansionError( |
| 284 | f'<use href="#{ref_id}"> has no matching element in this SVG' |
| 285 | ) |
| 286 | if ref_id in stack: |
| 287 | chain = ' -> '.join((*stack, ref_id)) |
| 288 | raise UseExpansionError(f'Circular local <use> reference: {chain}') |
| 289 | |
| 290 | target_tag = _local_tag(target) |
| 291 | if target_tag not in _REFERENCE_TAGS: |
| 292 | raise UseExpansionError( |
| 293 | f'<use href="#{ref_id}"> references unsupported <{target_tag}>' |
| 294 | ) |
| 295 | |
| 296 | self._reject_structural_metadata(use_elem, 'instance') |
| 297 | self._reject_structural_metadata(target, f'target #{ref_id}') |
| 298 | self._validate_fragment_reference_syntax(use_elem, 'instance') |
| 299 | self._validate_fragment_reference_syntax(target, f'target #{ref_id}') |
| 300 | |
| 301 | target_ids = { |
| 302 | elem_id |
| 303 | for elem in target.iter() |
| 304 | if (elem_id := elem.get('id')) |
| 305 | } |
| 306 | ambiguous_ids = sorted(target_ids & self.duplicate_ids) |
| 307 | if ambiguous_ids: |
| 308 | joined = ', '.join(ambiguous_ids) |
| 309 | raise UseExpansionError( |
| 310 | f'<use href="#{ref_id}"> references a subtree with duplicate id(s): ' |
| 311 | f'{joined}' |
| 312 | ) |
| 313 | |
| 314 | next_stack = (*stack, ref_id) |
| 315 | target_clone = copy.deepcopy(target) |
| 316 | if target_tag == 'use': |
| 317 | clone = self._materialize_use(target_clone, next_stack) |
| 318 | else: |
| 319 | clone = target_clone |
| 320 | if target_tag == 'symbol': |
| 321 | clone.tag = _qualified_tag(clone, 'g') |
| 322 | viewport_transform = _symbol_viewport_transform(target, use_elem) |
| 323 | existing_transform = clone.get('transform', '').strip() |
| 324 | clone.set( |
| 325 | 'transform', |
| 326 | f'{existing_transform} {viewport_transform}'.strip(), |
| 327 | ) |
| 328 | for attr in ('viewBox', 'preserveAspectRatio', 'x', 'y', 'width', 'height'): |
| 329 | clone.attrib.pop(attr, None) |
| 330 | self._expand_children(clone, next_stack) |
| 331 | |
| 332 | self._rewrite_clone_ids(clone, self._next_instance_prefix(clone)) |
| 333 | wrapper = self._build_wrapper(use_elem) |
| 334 | wrapper.append(clone) |
| 335 | self.expanded += 1 |
| 336 | return wrapper |
| 337 | |
| 338 | @staticmethod |
| 339 | def _reject_structural_metadata(elem: ET.Element, label: str) -> None: |
| 340 | """Reject reusable template/native markers parsed before expansion.""" |
| 341 | for candidate in elem.iter(): |
| 342 | for attr in candidate.attrib: |
| 343 | if attr.startswith(_NON_REUSABLE_METADATA_PREFIXES): |
| 344 | raise UseExpansionError( |
| 345 | f'Local <use> {label} cannot carry structural {attr} metadata' |
| 346 | ) |
| 347 | |
| 348 | def _validate_fragment_reference_syntax( |
| 349 | self, |
| 350 | elem: ET.Element, |
| 351 | label: str, |
| 352 | ) -> None: |
| 353 | """Reject URL fragment forms the clone rewriter cannot preserve.""" |
| 354 | for candidate in elem.iter(): |
| 355 | for value in candidate.attrib.values(): |
| 356 | starts = list(_URL_FUNCTION_START_RE.finditer(value)) |
| 357 | if not starts: |
| 358 | continue |
| 359 | functions = list(_URL_FUNCTION_RE.finditer(value)) |
| 360 | if len(functions) != len(starts): |
| 361 | raise UseExpansionError( |
| 362 | f'Local <use> {label} has malformed url(...) reference {value!r}' |
| 363 | ) |
| 364 | for function in functions: |
| 365 | raw = function.group(0) |
| 366 | match = _URL_REF_RE.fullmatch(raw) |
| 367 | if match is None: |
| 368 | raise UseExpansionError( |
| 369 | f'Local <use> {label} requires exact url(#id) fragments; ' |
| 370 | f'got {raw!r}' |
| 371 | ) |
| 372 | ref_id = match.group(1) |
| 373 | if ref_id in self.duplicate_ids: |
| 374 | raise UseExpansionError( |
| 375 | f'Local <use> {label} has ambiguous url(#{ref_id}); ' |
| 376 | 'the referenced id is duplicated' |
| 377 | ) |
| 378 | if ref_id not in self.targets: |
| 379 | raise UseExpansionError( |
| 380 | f'Local <use> {label} has unresolved url(#{ref_id})' |
| 381 | ) |
| 382 | |
| 383 | def _next_instance_prefix(self, clone: ET.Element) -> str: |
| 384 | """Reserve a deterministic clone prefix that cannot collide.""" |
| 385 | clone_ids = { |
| 386 | elem_id |
| 387 | for elem in clone.iter() |
| 388 | if (elem_id := elem.get('id')) |
| 389 | } |
| 390 | while True: |
| 391 | self.instance_index += 1 |
| 392 | prefix = f'use-instance-{self.instance_index}-' |
| 393 | generated_ids = {f'{prefix}{elem_id}' for elem_id in clone_ids} |
| 394 | if not generated_ids & self.used_ids: |
| 395 | self.used_ids.update(generated_ids) |
| 396 | return prefix |
| 397 | |
| 398 | @staticmethod |
| 399 | def _rewrite_clone_ids(clone: ET.Element, prefix: str) -> None: |
| 400 | """Make materialized IDs instance-local and rewrite fragment refs.""" |
| 401 | id_map: dict[str, str] = {} |
| 402 | for elem in clone.iter(): |
| 403 | elem_id = elem.get('id') |
| 404 | if elem_id: |
| 405 | id_map[elem_id] = f'{prefix}{elem_id}' |
| 406 | if not id_map: |
| 407 | return |
| 408 | for elem in clone.iter(): |
| 409 | elem_id = elem.get('id') |
| 410 | if elem_id in id_map: |
| 411 | elem.set('id', id_map[elem_id]) |
| 412 | for attr, value in list(elem.attrib.items()): |
| 413 | if attr in {'href', f'{{{XLINK_NS}}}href'} and value.startswith('#'): |
| 414 | ref_id = value[1:] |
| 415 | if ref_id in id_map: |
| 416 | elem.set(attr, f'#{id_map[ref_id]}') |
| 417 | continue |
| 418 | rewritten = _URL_REF_RE.sub( |
| 419 | lambda match: f'url(#{id_map.get(match.group(1), match.group(1))})', |
| 420 | value, |
| 421 | ) |
| 422 | if rewritten != value: |
| 423 | elem.set(attr, rewritten) |
| 424 | |
| 425 | @staticmethod |
| 426 | def _build_wrapper(use_elem: ET.Element) -> ET.Element: |
| 427 | """Create an inheriting group for use styles and instance geometry.""" |
| 428 | wrapper = ET.Element(_qualified_tag(use_elem, 'g')) |
| 429 | skipped = { |
| 430 | 'href', f'{{{XLINK_NS}}}href', |
| 431 | 'x', 'y', 'width', 'height', 'preserveAspectRatio', 'transform', |
| 432 | } |
| 433 | for attr, value in use_elem.attrib.items(): |
| 434 | if attr not in skipped: |
| 435 | wrapper.set(attr, value) |
| 436 | |
| 437 | x = _numeric_length(use_elem, 'x', 0.0) or 0.0 |
| 438 | y = _numeric_length(use_elem, 'y', 0.0) or 0.0 |
| 439 | transforms = [] |
| 440 | use_transform = use_elem.get('transform', '').strip() |
| 441 | if use_transform: |
| 442 | transforms.append(use_transform) |
| 443 | if x or y: |
| 444 | transforms.append(f'translate({_fmt_number(x)} {_fmt_number(y)})') |
| 445 | if transforms: |
| 446 | wrapper.set('transform', ' '.join(transforms)) |
| 447 | return wrapper |
| 448 | |
| 449 | |
| 450 | def expand_local_use_references(root: ET.Element) -> int: |
| 451 | """Expand static same-document ``<use href="#id">`` references.""" |
| 452 | return _LocalUseExpander(root).expand() |
| 453 | |
| 454 | |
| 455 | def validate_local_use_references(root: ET.Element) -> list[str]: |
| 456 | """Return expansion errors without mutating the caller's SVG tree.""" |
| 457 | try: |
| 458 | expand_local_use_references(copy.deepcopy(root)) |
| 459 | except UseExpansionError as exc: |
| 460 | return [str(exc)] |
| 461 | return [] |
| 462 | |
| 463 | |
| 464 | def expand_local_use_references_in_file(svg_path: Path) -> int: |
| 465 | """Expand local use references in one SVG file in place.""" |
| 466 | tree = ET.parse(str(svg_path)) |
| 467 | count = expand_local_use_references(tree.getroot()) |
| 468 | if count: |
| 469 | ET.register_namespace('', SVG_NS) |
| 470 | ET.register_namespace('xlink', XLINK_NS) |
| 471 | tree.write(str(svg_path), encoding='unicode', xml_declaration=False) |
| 472 | return count |
| 473 | |
| 474 | |
| 475 | def _import_embed_icons(): |
| 476 | """Lazy import so svg_to_pptx doesn't hard-require svg_finalize at import time.""" |
| 477 | scripts_dir = Path(__file__).resolve().parent.parent |
| 478 | if str(scripts_dir) not in sys.path: |
| 479 | sys.path.insert(0, str(scripts_dir)) |
| 480 | from svg_finalize import embed_icons # type: ignore |
| 481 | return embed_icons |
| 482 | |
| 483 | |
| 484 | def _build_replacement_g( |
| 485 | use_elem: ET.Element, |
| 486 | icons_dir: Path, |
| 487 | fallback_dir: Path | None, |
| 488 | embed_icons_mod, |
| 489 | ) -> ET.Element | None: |
| 490 | """Resolve a single ``<use data-icon="...">`` into an expanded ``<g>``. |
| 491 | |
| 492 | Returns None when the icon name is missing, unresolved, or the icon |
| 493 | file cannot be parsed. Callers should leave the original ``<use>`` in |
| 494 | place in that case (matching the on-disk finalize_svg behaviour, which |
| 495 | also leaves unresolvable placeholders untouched). |
| 496 | """ |
| 497 | use_str = ET.tostring(use_elem, encoding='unicode') |
| 498 | attrs = embed_icons_mod.parse_use_element(use_str) |
| 499 | if 'icon' not in attrs: |
| 500 | return None |
| 501 | |
| 502 | icon_path, _base_size = embed_icons_mod.resolve_icon_path( |
| 503 | attrs['icon'], icons_dir, fallback_dir, |
| 504 | ) |
| 505 | if not icon_path.exists(): |
| 506 | return None |
| 507 | |
| 508 | color = attrs.get('fill', '#000000') |
| 509 | elements, style, base_size = embed_icons_mod.extract_paths_from_icon( |
| 510 | icon_path, color, |
| 511 | ) |
| 512 | if not elements: |
| 513 | return None |
| 514 | |
| 515 | g_xml = embed_icons_mod.generate_icon_group(attrs, elements, style, base_size) |
| 516 | |
| 517 | # Wrap with a namespaced root so the parsed subtree carries the SVG |
| 518 | # namespace through to every primitive (path/circle/...). |
| 519 | wrapped = f'<svg xmlns="{SVG_NS}">{g_xml}</svg>' |
| 520 | try: |
| 521 | parsed_root = ET.fromstring(wrapped) |
| 522 | except ET.ParseError: |
| 523 | return None |
| 524 | |
| 525 | for child in parsed_root: |
| 526 | local = child.tag.split('}')[-1] if '}' in child.tag else child.tag |
| 527 | if local == 'g': |
| 528 | return child |
| 529 | return None |
| 530 | |
| 531 | |
| 532 | def expand_use_data_icons( |
| 533 | root: ET.Element, |
| 534 | icons_dir: Path, |
| 535 | fallback_dir: Path | None = None, |
| 536 | ) -> int: |
| 537 | """Replace every ``<use data-icon="...">`` in *root* with its expansion. |
| 538 | |
| 539 | Walks the tree, finds use elements that carry a ``data-icon`` attribute, |
| 540 | builds a new ``<g>`` subtree from the project icon library (falling back to |
| 541 | the global library when supplied), and swaps it into the parent element at |
| 542 | the same position. |
| 543 | |
| 544 | Returns the number of placeholders successfully expanded. Unresolvable |
| 545 | placeholders are left in place so callers can decide whether to warn. |
| 546 | """ |
| 547 | if not icons_dir.exists(): |
| 548 | return 0 |
| 549 | |
| 550 | embed_icons_mod = _import_embed_icons() |
| 551 | |
| 552 | # ElementTree elements don't carry a parent reference, so build a map. |
| 553 | parent_of: dict[ET.Element, ET.Element] = {} |
| 554 | for parent in root.iter(): |
| 555 | for child in parent: |
| 556 | parent_of[child] = parent |
| 557 | |
| 558 | targets: list[ET.Element] = [] |
| 559 | for elem in root.iter(): |
| 560 | local = elem.tag.split('}')[-1] if '}' in elem.tag else elem.tag |
| 561 | if local == 'use' and elem.get('data-icon'): |
| 562 | targets.append(elem) |
| 563 | |
| 564 | expanded = 0 |
| 565 | for use_elem in targets: |
| 566 | parent = parent_of.get(use_elem) |
| 567 | if parent is None: |
| 568 | continue |
| 569 | replacement = _build_replacement_g(use_elem, icons_dir, fallback_dir, embed_icons_mod) |
| 570 | if replacement is None: |
| 571 | continue |
| 572 | idx = list(parent).index(use_elem) |
| 573 | parent.remove(use_elem) |
| 574 | parent.insert(idx, replacement) |
| 575 | expanded += 1 |
| 576 | |
| 577 | return expanded |
| 578 |