返回 ppt-master
use_expander.py
根目录 / skills / ppt-master / scripts / svg_to_pptx / use_expander.py
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
578 lines PYTHON