返回 ppt-master
converter.py
1 """Core SVG -> DrawingML dispatcher, group handling, and main entry point."""
2
3 from __future__ import annotations
4
5 import base64
6 import binascii
7 import hashlib
8 import math
9 import re
10 from pathlib import Path
11 from typing import Any
12 from xml.etree import ElementTree as ET
13
14 from native_payloads import NativePayloadError, hydrate_native_payload_refs
15 from pptx_shapes import (
16 has_relationship_attributes,
17 resolve_preset_preview_hash,
18 svg_preset_preview_fingerprint,
19 svg_text_fingerprint,
20 validate_ooxml_xfrm,
21 )
22 from pptx_effects import project_effect_status_errors, txbody_has_run_effects
23 from pptx_to_svg.preset_authoring import (
24 materialize_compact_authored_preset_tree,
25 validate_authored_preset_tree,
26 )
27 from resource_paths import icon_search_dirs_for_svg
28
29 from .context import (
30 TEXT_FLOW_PRESERVE,
31 TEXT_FLOW_SPLIT,
32 ConvertContext,
33 ShapeResult,
34 resolve_text_flow,
35 )
36 from .paths import (
37 project_freeform_geometry_errors,
38 project_gradient_geometry_errors,
39 )
40 from .theme_colors import ThemeColorSpec
41 from .theme_fonts import ThemeFontSpec
42 from .text_properties import (
43 materialize_project_text_metrics,
44 project_text_property_errors,
45 resolve_project_font_sizes,
46 resolve_project_letter_spacings,
47 )
48 from .utils import (
49 EMU_PER_PX,
50 SVG_NS,
51 _extract_inheritable_styles,
52 _get_attr,
53 _is_unit_axis_reflection,
54 is_picture_effect_carrier,
55 parse_svg_length,
56 parse_transform_operations,
57 parse_transform_matrix,
58 project_definition_errors,
59 project_definition_index,
60 project_filter_errors,
61 project_geometry_length_errors,
62 project_gradient_errors,
63 project_image_aspect_ratio_errors,
64 project_mask_errors,
65 project_marker_errors,
66 project_opacity_errors,
67 project_paint_errors,
68 project_paint_reference_errors,
69 project_stroke_style_errors,
70 project_transform_errors,
71 resolve_url_id,
72 supports_full_project_transform,
73 validate_dml_shape_matrix,
74 )
75 from .styles import (
76 build_effect_xml, build_fill_xml,
77 get_element_opacity, get_fill_opacity, get_stroke_opacity,
78 )
79 from .elements import (
80 convert_rect, convert_circle, convert_ellipse,
81 convert_line, convert_path,
82 convert_polygon, convert_polyline,
83 convert_text, convert_image, convert_nested_svg,
84 project_clip_path_errors,
85 project_image_errors,
86 project_nested_svg_crop_errors,
87 )
88 from ..animation_config import is_chrome_id, usable_animation_group_id
89 from ..canvas_contract import (
90 CanvasContractError,
91 parse_project_svg_root,
92 parse_project_viewbox,
93 )
94 from ..native_objects import (
95 NativeMarkerAttributeError,
96 convert_native_object,
97 native_metadata_payload_matches,
98 native_replacement_kind,
99 native_marker_transform,
100 snapshot_native_fallback_freshness,
101 )
102 from ..native_objects.marker_status import native_marker_status_errors
103 from ..semantic_markers import is_static_page_frame
104
105
106 class SvgNativeConversionError(RuntimeError):
107 """Raised when an SVG cannot be faithfully converted to native DrawingML."""
108
109
110 def _hydrate_native_payloads(root: ET.Element, svg_path: Path) -> int:
111 """Resolve compressed workspace payload references for native conversion."""
112 try:
113 return hydrate_native_payload_refs(root, svg_path)
114 except NativePayloadError as exc:
115 raise SvgNativeConversionError(
116 f"{svg_path.name}: invalid native payload reference: {exc}"
117 ) from exc
118
119
120 def _require_chart_table_marker_attributes(
121 root: ET.Element,
122 svg_path: Path | str,
123 ) -> None:
124 """Reject contradictory chart/table marker aliases before either route."""
125 errors: list[str] = []
126 for elem in root.iter():
127 if elem.tag.rsplit('}', 1)[-1] == 'metadata':
128 continue
129 marker_errors = native_marker_status_errors(elem)
130 if marker_errors:
131 marker_id = elem.get('id') or elem.get('data-name') or '<unnamed>'
132 errors.extend(f'{marker_id}: {error}' for error in marker_errors)
133 continue
134 marker_id = elem.get('id') or elem.get('data-name') or '<unnamed>'
135 kind = native_replacement_kind(elem)
136 if not kind:
137 continue
138 for child in elem:
139 if child.tag.rsplit('}', 1)[-1] != 'metadata':
140 continue
141 try:
142 native_metadata_payload_matches(child, kind)
143 except NativeMarkerAttributeError as exc:
144 errors.append(f'{marker_id}: {exc}')
145 if not errors:
146 return
147 preview = '; '.join(errors[:8])
148 suffix = '' if len(errors) <= 8 else f'; +{len(errors) - 8} more'
149 raise SvgNativeConversionError(
150 f'{Path(svg_path).name}: invalid chart/table replacement metadata: '
151 f'{preview}{suffix}'
152 )
153
154
155 def _require_project_freeform_geometry(
156 root: ET.Element,
157 svg_path: Path | str,
158 ) -> None:
159 """Reject malformed path and points values with one aggregated error."""
160 errors = project_freeform_geometry_errors(root)
161 if not errors:
162 return
163 preview = '; '.join(errors[:8])
164 suffix = '' if len(errors) <= 8 else f'; +{len(errors) - 8} more'
165 raise SvgNativeConversionError(
166 f'{Path(svg_path).name}: invalid project freeform geometry: '
167 f'{preview}{suffix}'
168 )
169
170
171 def _require_project_nested_svg_crops(
172 root: ET.Element,
173 svg_path: Path | str,
174 ) -> None:
175 """Reject nested SVG outside the imported picture-crop transport."""
176 errors = project_nested_svg_crop_errors(root)
177 if not errors:
178 return
179 preview = '; '.join(errors[:8])
180 suffix = '' if len(errors) <= 8 else f'; +{len(errors) - 8} more'
181 raise SvgNativeConversionError(
182 f'{Path(svg_path).name}: invalid nested SVG crop wrapper(s): '
183 f'{preview}{suffix}'
184 )
185
186
187 def _require_project_clip_paths(
188 root: ET.Element,
189 svg_path: Path | str,
190 ) -> None:
191 """Reject clip references that cannot produce native picture geometry."""
192 errors = project_clip_path_errors(root)
193 if not errors:
194 return
195 preview = '; '.join(errors[:8])
196 suffix = '' if len(errors) <= 8 else f'; +{len(errors) - 8} more'
197 raise SvgNativeConversionError(
198 f'{Path(svg_path).name}: invalid project clip-path(s): '
199 f'{preview}{suffix}'
200 )
201
202
203 def _require_project_images(
204 root: ET.Element,
205 svg_path: Path | str,
206 ) -> None:
207 """Reject invalid picture frames and unresolved or corrupt sources."""
208 path = Path(svg_path)
209 errors = project_image_errors(root, path.parent)
210 if not errors:
211 return
212 preview = '; '.join(errors[:8])
213 suffix = '' if len(errors) <= 8 else f'; +{len(errors) - 8} more'
214 raise SvgNativeConversionError(
215 f'{path.name}: invalid project image(s): {preview}{suffix}'
216 )
217
218
219 def _require_project_transforms(
220 root: ET.Element,
221 svg_path: Path | str,
222 ) -> None:
223 """Reject invalid project transform syntax and mappings before conversion."""
224 errors = project_transform_errors(root)
225 if not errors:
226 return
227 preview = '; '.join(errors[:8])
228 suffix = '' if len(errors) <= 8 else f'; +{len(errors) - 8} more'
229 raise SvgNativeConversionError(
230 f'{Path(svg_path).name}: invalid project transform(s): '
231 f'{preview}{suffix}'
232 )
233
234
235 def _require_project_stroke_styles(
236 root: ET.Element,
237 svg_path: Path | str,
238 ) -> None:
239 """Reject invalid project line-style syntax and mappings before conversion."""
240 errors = project_stroke_style_errors(root)
241 if not errors:
242 return
243 preview = '; '.join(errors[:8])
244 suffix = '' if len(errors) <= 8 else f'; +{len(errors) - 8} more'
245 raise SvgNativeConversionError(
246 f'{Path(svg_path).name}: invalid project line style(s): '
247 f'{preview}{suffix}'
248 )
249
250
251 def _require_project_image_aspect_ratios(
252 root: ET.Element,
253 svg_path: Path | str,
254 ) -> None:
255 """Reject ambiguous image fit/crop values before native conversion."""
256 errors = project_image_aspect_ratio_errors(root)
257 if not errors:
258 return
259 preview = '; '.join(errors[:8])
260 suffix = '' if len(errors) <= 8 else f'; +{len(errors) - 8} more'
261 raise SvgNativeConversionError(
262 f'{Path(svg_path).name}: invalid project image aspect ratio(s): '
263 f'{preview}{suffix}'
264 )
265
266
267 def _require_project_opacities(
268 root: ET.Element,
269 svg_path: Path | str,
270 ) -> None:
271 """Reject malformed opacity values before native conversion."""
272 errors = project_opacity_errors(root)
273 if not errors:
274 return
275 preview = '; '.join(errors[:8])
276 suffix = '' if len(errors) <= 8 else f'; +{len(errors) - 8} more'
277 raise SvgNativeConversionError(
278 f'{Path(svg_path).name}: invalid project opacity value(s): '
279 f'{preview}{suffix}'
280 )
281
282
283 def _require_project_paints(
284 root: ET.Element,
285 svg_path: Path | str,
286 ) -> None:
287 """Reject invalid paint values before native conversion."""
288 errors = project_paint_errors(root)
289 if not errors:
290 return
291 preview = '; '.join(errors[:8])
292 suffix = '' if len(errors) <= 8 else f'; +{len(errors) - 8} more'
293 raise SvgNativeConversionError(
294 f'{Path(svg_path).name}: invalid project paint value(s): '
295 f'{preview}{suffix}'
296 )
297
298
299 def _require_project_masks(
300 root: ET.Element,
301 svg_path: Path | str,
302 ) -> None:
303 """Reject SVG masks before native conversion can silently drop them."""
304 errors = project_mask_errors(root)
305 if not errors:
306 return
307 preview = '; '.join(errors[:8])
308 suffix = '' if len(errors) <= 8 else f'; +{len(errors) - 8} more'
309 raise SvgNativeConversionError(
310 f'{Path(svg_path).name}: invalid project mask(s): '
311 f'{preview}{suffix}'
312 )
313
314
315 def _require_project_definitions(
316 root: ET.Element,
317 svg_path: Path | str,
318 ) -> None:
319 """Reject definitions outside the direct, unique local-ref contract."""
320 errors = project_definition_errors(root)
321 if not errors:
322 return
323 preview = '; '.join(errors[:8])
324 suffix = '' if len(errors) <= 8 else f'; +{len(errors) - 8} more'
325 raise SvgNativeConversionError(
326 f'{Path(svg_path).name}: invalid project definition(s): '
327 f'{preview}{suffix}'
328 )
329
330
331 def _require_project_paint_references(
332 root: ET.Element,
333 svg_path: Path | str,
334 ) -> None:
335 """Reject unresolved or context-invalid local paint references."""
336 errors = project_paint_reference_errors(root)
337 if not errors:
338 return
339 preview = '; '.join(errors[:8])
340 suffix = '' if len(errors) <= 8 else f'; +{len(errors) - 8} more'
341 raise SvgNativeConversionError(
342 f'{Path(svg_path).name}: invalid project paint reference(s): '
343 f'{preview}{suffix}'
344 )
345
346
347 def _require_project_line_end_markers(
348 root: ET.Element,
349 svg_path: Path | str,
350 ) -> None:
351 """Reject markers outside the native line-end contract."""
352 errors = project_marker_errors(root)
353 if not errors:
354 return
355 preview = '; '.join(errors[:8])
356 suffix = '' if len(errors) <= 8 else f'; +{len(errors) - 8} more'
357 raise SvgNativeConversionError(
358 f'{Path(svg_path).name}: invalid project line-end marker(s): '
359 f'{preview}{suffix}'
360 )
361
362
363 def _require_project_gradients(
364 root: ET.Element,
365 svg_path: Path | str,
366 ) -> None:
367 """Reject gradients outside the normalized native interface."""
368 errors = project_gradient_errors(root) + project_gradient_geometry_errors(root)
369 if not errors:
370 return
371 preview = '; '.join(errors[:8])
372 suffix = '' if len(errors) <= 8 else f'; +{len(errors) - 8} more'
373 raise SvgNativeConversionError(
374 f'{Path(svg_path).name}: invalid project gradient(s): '
375 f'{preview}{suffix}'
376 )
377
378
379 def _require_project_filters(
380 root: ET.Element,
381 svg_path: Path | str,
382 ) -> None:
383 """Reject filters outside the native shadow/glow interface."""
384 errors = project_filter_errors(root)
385 if not errors:
386 return
387 preview = '; '.join(errors[:8])
388 suffix = '' if len(errors) <= 8 else f'; +{len(errors) - 8} more'
389 raise SvgNativeConversionError(
390 f'{Path(svg_path).name}: invalid project filter(s): '
391 f'{preview}{suffix}'
392 )
393
394
395 def _require_project_effect_status(
396 root: ET.Element,
397 svg_path: Path | str,
398 ) -> None:
399 """Reject source effects that the importer cannot map without distortion."""
400 errors = project_effect_status_errors(root)
401 if not errors:
402 return
403 preview = '; '.join(errors[:8])
404 suffix = '' if len(errors) <= 8 else f'; +{len(errors) - 8} more'
405 raise SvgNativeConversionError(
406 f'{Path(svg_path).name}: unsupported imported PPTX effect(s): '
407 f'{preview}{suffix}'
408 )
409
410
411 def _require_project_text_properties(
412 root: ET.Element,
413 svg_path: Path | str,
414 ) -> None:
415 """Reject text declarations outside the closed DrawingML mapping."""
416 errors = project_text_property_errors(root)
417 if not errors:
418 return
419 preview = '; '.join(errors[:8])
420 suffix = '' if len(errors) <= 8 else f'; +{len(errors) - 8} more'
421 raise SvgNativeConversionError(
422 f'{Path(svg_path).name}: invalid project text property(s): '
423 f'{preview}{suffix}'
424 )
425
426
427 # ---------------------------------------------------------------------------
428 # Transform & layout helpers
429 # ---------------------------------------------------------------------------
430
431 def parse_transform(transform_str: str) -> tuple[float, float, float, float, float]:
432 """Parse an SVG transform list into (dx, dy, sx, sy, angle_deg).
433
434 Composes every translate/scale/rotate/matrix operation rather than picking
435 the first occurrence — needed for idioms like
436 ``translate(cx cy) scale(-1 -1) translate(-cx -cy)`` which encode a flip
437 around a non-origin pivot.
438
439 When the composed matrix has no rotation, the decomposition preserves
440 signed scale for flips. With rotation, scale uses the column magnitudes and
441 angle uses the first transformed axis. Zero or non-orthogonal axes fail
442 before decomposition because this tuple cannot represent them faithfully.
443 """
444 if not transform_str:
445 return 0.0, 0.0, 1.0, 1.0, 0.0
446
447 matrix = parse_transform_matrix(transform_str)
448 validate_dml_shape_matrix(matrix)
449 a, b, c, d, e, f = matrix
450
451 # No shear / rotation: direct decomposition preserves the original signs of
452 # sx / sy. ctx_x / ctx_y use the simple ``val * sx + tx`` formula, so this
453 # is the only form that survives flip-around-pivot composites without
454 # collapsing them into a rotation that the consumer can't honour.
455 if abs(b) < 1e-9 and abs(c) < 1e-9:
456 sx = a if a != 0 else 1.0
457 sy = d if d != 0 else 1.0
458 return e, f, sx, sy, 0.0
459
460 sx = math.hypot(a, b)
461 sy = math.hypot(c, d)
462 if sx == 0:
463 sx = 1.0
464 if sy == 0:
465 sy = 1.0
466
467 angle_deg = math.degrees(math.atan2(b, a))
468 return e, f, sx, sy, angle_deg
469
470
471 # ``rotate(angle)`` defaults to pivot (0,0); ``rotate(angle, cx, cy)`` rotates
472 # around (cx, cy). DrawingML grpSp ``rot`` always rotates around the group's
473 # own bounding-box centre — we need the SVG pivot so ``convert_g`` can
474 # compensate for the offset between those two centres.
475 def _root_viewport_size(root: ET.Element) -> tuple[float, float]:
476 """Return the SVG root viewport size in user units."""
477 viewbox = parse_project_viewbox(root.get('viewBox'))
478 return float(viewbox.width), float(viewbox.height)
479
480
481 def _extract_rotate_pivot(transform_str: str) -> tuple[float, float] | None:
482 """Return the (cx, cy) pivot of a sole ``rotate(...)`` in *transform_str*.
483
484 Returns ``None`` when the transform list contains anything other than one
485 rotate (other ops compose with rotate in a way the pivot-compensation
486 fallback can't express). A bare ``rotate(angle)`` returns (0, 0).
487 """
488 if not transform_str:
489 return None
490 operations = parse_transform_operations(transform_str)
491 if len(operations) != 1 or operations[0][0] != 'rotate':
492 return None
493 args = operations[0][1]
494 cx = args[1] if len(args) == 3 else 0.0
495 cy = args[2] if len(args) == 3 else 0.0
496 return cx, cy
497
498
499 def _txbody_metadata(elem: ET.Element) -> ET.Element | None:
500 for child in elem:
501 if (
502 child.tag.replace(f'{{{SVG_NS}}}', '') == 'metadata'
503 and child.get('data-pptx-part') == 'txbody'
504 ):
505 return child
506 return None
507
508
509 _TXBODY_UNCHANGED_ATTR = 'data-pptx-runtime-txbody-unchanged'
510 _PREVIEW_UNCHANGED_ATTR = 'data-pptx-runtime-preview-unchanged'
511
512
513 def _mark_unchanged_txbody_groups(root: ET.Element) -> None:
514 """Snapshot author-visible text state before exporter preprocessing."""
515 for group in root.iter():
516 if group.tag.replace(f'{{{SVG_NS}}}', '') != 'g':
517 continue
518 metadata = _txbody_metadata(group)
519 if metadata is None:
520 continue
521 expected = metadata.get('data-pptx-text-sha256')
522 actual = svg_text_fingerprint(group)
523 group.set(_TXBODY_UNCHANGED_ATTR, '1' if expected == actual else '0')
524
525
526 def _mark_unchanged_preset_previews(root: ET.Element) -> None:
527 """Snapshot visible preset layers before exporter preprocessing."""
528 for group in root.iter():
529 if group.tag.replace(f'{{{SVG_NS}}}', '') != 'g':
530 continue
531 if (
532 group.get('data-pptx-object') not in {'shape', 'connector'}
533 or group.get('data-pptx-prst') is None
534 ):
535 continue
536 try:
537 expected = resolve_preset_preview_hash(group)
538 except ValueError as exc:
539 raise SvgNativeConversionError(
540 f'Invalid preset preview fingerprint contract: {exc}'
541 ) from exc
542 if expected is None:
543 continue
544 actual = svg_preset_preview_fingerprint(group)
545 group.set(_PREVIEW_UNCHANGED_ATTR, '1' if expected == actual else '0')
546
547
548 def _require_unchanged_preset_preview(group: ET.Element) -> None:
549 try:
550 expected = resolve_preset_preview_hash(group)
551 except ValueError as exc:
552 raise SvgNativeConversionError(
553 f'Invalid preset preview fingerprint contract: {exc}'
554 ) from exc
555 if expected is None:
556 return
557 snapshot = group.get(_PREVIEW_UNCHANGED_ATTR)
558 if snapshot == '1':
559 return
560 if snapshot is None and svg_preset_preview_fingerprint(group) == expected:
561 return
562 raise SvgNativeConversionError(
563 'Visible preset preview was edited without updating its native '
564 'data-pptx-prst/frame/adjustment carrier; export stopped to avoid '
565 'silently discarding the SVG edit'
566 )
567
568
569 def _decode_unchanged_txbody(
570 group: ET.Element,
571 metadata: ET.Element,
572 *,
573 trust_runtime_snapshot: bool = True,
574 ) -> tuple[str, bool] | None:
575 expected_hash = metadata.get('data-pptx-text-sha256')
576 if not expected_hash:
577 raise SvgNativeConversionError('txbody metadata requires a text hash')
578 snapshot = (
579 group.get(_TXBODY_UNCHANGED_ATTR)
580 if trust_runtime_snapshot else None
581 )
582 unchanged = snapshot == '1' or (
583 snapshot != '0'
584 and svg_text_fingerprint(group) == expected_hash
585 )
586 if metadata.get('data-pptx-encoding') != 'base64':
587 raise SvgNativeConversionError('txbody metadata requires base64 encoding')
588 try:
589 raw = base64.b64decode((metadata.text or '').strip(), validate=True)
590 txbody = ET.fromstring(raw)
591 decoded = raw.decode('utf-8')
592 except (ValueError, binascii.Error, UnicodeDecodeError, ET.ParseError) as exc:
593 raise SvgNativeConversionError(f'Invalid txbody metadata: {exc}') from exc
594 if txbody.tag != (
595 '{http://schemas.openxmlformats.org/presentationml/2006/main}txBody'
596 ):
597 raise SvgNativeConversionError('txbody metadata payload must be p:txBody')
598 if has_relationship_attributes(txbody):
599 raise SvgNativeConversionError(
600 'txbody metadata must not contain part-local relationship attributes'
601 )
602 if not unchanged:
603 if txbody_has_run_effects(txbody):
604 raise SvgNativeConversionError(
605 'Visible text or typography was edited while the source '
606 'txBody contains run-level effects; export stopped to avoid '
607 'silently discarding those effects'
608 )
609 return None
610 return decoded, txbody_has_run_effects(txbody)
611
612
613 def _append_shape_text(
614 shape: ShapeResult,
615 txbody_xml: str,
616 ) -> ShapeResult:
617 if not shape.xml.lstrip().startswith('<p:sp>') or not shape.xml.rstrip().endswith('</p:sp>'):
618 raise SvgNativeConversionError('Native txBody can only attach to p:sp')
619 closing = shape.xml.rfind('</p:sp>')
620 return ShapeResult(
621 xml=(
622 shape.xml[:closing]
623 + txbody_xml
624 + '\n'
625 + shape.xml[closing:]
626 ),
627 bounds_emu=shape.bounds_emu,
628 )
629
630
631 def preserved_native_text_body(
632 group: ET.Element,
633 *,
634 trust_runtime_snapshot: bool = True,
635 ) -> tuple[ET.Element, str] | None:
636 """Return the geometry carrier and unchanged native text body, if usable."""
637 metadata = _txbody_metadata(group)
638 logical_text_shape = (
639 group.get('data-pptx-object') == 'shape'
640 and (
641 group.get('data-pptx-prst') is not None
642 or group.get('data-pptx-geometry-kind') == 'custom'
643 )
644 and metadata is not None
645 )
646 if not logical_text_shape:
647 return None
648 decoded_text = _decode_unchanged_txbody(
649 group,
650 metadata,
651 trust_runtime_snapshot=trust_runtime_snapshot,
652 )
653 carrier_children = [
654 child for child in group
655 if child.get('data-pptx-part') == 'geometry'
656 ]
657 allowed_parts = {
658 'geometry',
659 'geometry-detail',
660 'geometry-preview',
661 'txbody',
662 }
663 has_foreign_visual = any(
664 child.tag.replace(f'{{{SVG_NS}}}', '') not in {'text', 'metadata'}
665 and child.get('data-pptx-part') not in allowed_parts
666 for child in group
667 )
668 if decoded_text is None:
669 return None
670 native_text, has_run_effects = decoded_text
671 if len(carrier_children) != 1 or has_foreign_visual:
672 if has_run_effects:
673 raise SvgNativeConversionError(
674 'The source txBody contains run-level effects but cannot be '
675 'restored as one native text shape; export stopped to avoid '
676 'silently discarding those effects'
677 )
678 return None
679 return carrier_children[0], native_text
680
681
682 # ---------------------------------------------------------------------------
683 # Group handling
684 # ---------------------------------------------------------------------------
685
686 def convert_g(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
687 """Convert SVG <g> to DrawingML group shape <p:grpSp>.
688
689 Preserves group structure so elements can be selected and moved together
690 in PowerPoint. Single-child groups are flattened to avoid unnecessary nesting.
691
692 Uses identity coordinate mapping (chOff/chExt == off/ext) so child shapes
693 keep their absolute slide coordinates unchanged.
694 """
695 transform = elem.get('transform', '')
696 native_subtree_active = ctx.native_objects_enabled and any(
697 native_replacement_kind(descendant)
698 and descendant.tag.replace(f'{{{SVG_NS}}}', '') != 'metadata'
699 for descendant in elem.iter()
700 )
701 if native_subtree_active:
702 dx, dy, sx, sy = native_marker_transform(transform)
703 angle_deg = 0.0
704 else:
705 dx, dy, sx, sy, angle_deg = parse_transform(transform)
706
707 filter_id = resolve_url_id(elem.get('filter', ''))
708 style_overrides = _extract_inheritable_styles(elem)
709 local_opacity = get_element_opacity(elem)
710 if local_opacity is None:
711 local_opacity = 1.0
712
713 elem_id = usable_animation_group_id(elem.get('id'))
714 semantic_role = elem.get('data-pptx-role')
715 placeholder = elem.get('data-pptx-placeholder')
716 has_explicit_semantics = (
717 semantic_role is not None or placeholder is not None
718 )
719 is_chrome = (
720 is_static_page_frame(semantic_role, placeholder)
721 if has_explicit_semantics
722 else is_chrome_id(elem_id)
723 )
724 should_animate_group = (
725 ctx.depth == 0
726 and elem_id
727 and (
728 not is_chrome
729 or (
730 not has_explicit_semantics
731 and elem_id in ctx.animation_group_overrides
732 )
733 )
734 and elem.get('data-pptx-layer') is None
735 )
736 visual_children = [
737 child for child in elem
738 if child.tag.replace(f'{{{SVG_NS}}}', '') not in _NON_VISUAL_TAGS
739 ]
740 unit_axis_reflection = (
741 bool(transform)
742 and _is_unit_axis_reflection(parse_transform_operations(transform))
743 )
744 matrix_supported = (
745 not native_subtree_active
746 and bool(transform)
747 and visual_children
748 and (
749 supports_full_project_transform(elem)
750 or unit_axis_reflection
751 )
752 )
753 # A pure ``rotate(angle [cx cy])`` falls through to the fallback path
754 # below (children are rect/text/path/etc. that don't consume a full
755 # matrix). Decomposing the matrix produces translation components
756 # (e, f) that encode the pivot — handing those to children would
757 # *double-translate* them because grpSp's own ``rot`` already
758 # rotates around the group's bounding-box centre. Skip the child
759 # translation here and apply pivot-centre compensation to ``a:off``
760 # below instead.
761 rotate_pivot = _extract_rotate_pivot(transform) if not matrix_supported else None
762 if rotate_pivot is not None:
763 angle_deg = parse_transform_operations(transform)[0][1][0]
764 if matrix_supported:
765 child_ctx = ctx.child(
766 0, 0, 1.0, 1.0,
767 transform_matrix=parse_transform_matrix(transform),
768 filter_id=filter_id,
769 style_overrides=style_overrides,
770 opacity_multiplier=local_opacity,
771 )
772 elif rotate_pivot is not None:
773 child_ctx = ctx.child(
774 0, 0, 1.0, 1.0,
775 filter_id=filter_id,
776 style_overrides=style_overrides,
777 opacity_multiplier=local_opacity,
778 )
779 else:
780 child_ctx = ctx.child(
781 ctx.scale_x * dx if native_subtree_active else dx,
782 ctx.scale_y * dy if native_subtree_active else dy,
783 sx,
784 sy,
785 filter_id=filter_id,
786 style_overrides=style_overrides,
787 opacity_multiplier=local_opacity,
788 )
789
790 if native_subtree_active and child_ctx.opacity_multiplier < 1.0:
791 raise SvgNativeConversionError(
792 "Group opacity cannot be applied to data-pptx-replace-with chart/table "
793 "objects; export without --native-charts-and-tables to use the "
794 "shape-based SVG fallback"
795 )
796
797 if child_ctx.native_objects_enabled:
798 native_result = convert_native_object(elem, child_ctx)
799 if native_result:
800 ctx.sync_from_child(child_ctx)
801 if should_animate_group:
802 shape_match = re.search(r'<p:cNvPr id="(\d+)"', native_result.xml)
803 if shape_match:
804 ctx.anim_targets.append((int(shape_match.group(1)), elem_id))
805 return native_result
806
807 if (
808 elem.get('data-pptx-object') in {'shape', 'connector'}
809 and elem.get('data-pptx-prst') is not None
810 ):
811 _require_unchanged_preset_preview(elem)
812
813 preserved_text = preserved_native_text_body(elem)
814 if preserved_text is not None:
815 geometry_carrier, native_text = preserved_text
816 geometry_ctx = child_ctx
817 if transform and not native_subtree_active:
818 geometry_ctx = ctx.child(
819 0, 0, 1.0, 1.0,
820 transform_matrix=parse_transform_matrix(transform),
821 filter_id=filter_id,
822 style_overrides=style_overrides,
823 opacity_multiplier=local_opacity,
824 )
825 geometry_result = convert_element(geometry_carrier, geometry_ctx)
826 ctx.sync_from_child(geometry_ctx)
827 if geometry_result is None:
828 raise SvgNativeConversionError(
829 'Logical text shape has no convertible geometry carrier'
830 )
831 restored = _append_shape_text(
832 geometry_result,
833 native_text,
834 )
835 if should_animate_group and elem_id:
836 shape_match = re.search(r'<p:cNvPr id="(\d+)"', restored.xml)
837 if shape_match:
838 ctx.anim_targets.append((int(shape_match.group(1)), elem_id))
839 return restored
840
841 child_results: list[ShapeResult] = []
842 for child in elem:
843 result = convert_element(child, child_ctx)
844 if result:
845 child_results.append(result)
846
847 ctx.sync_from_child(child_ctx)
848
849 if not child_results:
850 return None
851
852 # A logical imported preset may contain several render-only SVG detail
853 # paths, but after those are skipped it owns exactly one native object.
854 # Flatten that carrier even at the top level; otherwise animation grouping
855 # would turn one source ``p:sp`` into a ``p:grpSp`` wrapper. Retarget an
856 # optional animation to the restored leaf shape ID.
857 logical_native_shape_group = (
858 elem.get('data-pptx-object') in {'shape', 'connector'}
859 and (
860 elem.get('data-pptx-prst') is not None
861 or elem.get('data-pptx-geometry-kind') == 'custom'
862 )
863 )
864 logical_picture_effect_group = (
865 filter_id is not None
866 and is_picture_effect_carrier(elem)
867 )
868 explicit_native_group = elem.get('data-pptx-object') == 'group'
869 if (
870 len(child_results) == 1
871 and not explicit_native_group
872 and (
873 not should_animate_group
874 or logical_native_shape_group
875 or logical_picture_effect_group
876 )
877 ):
878 if should_animate_group and elem_id:
879 shape_match = re.search(r'<p:cNvPr id="(\d+)"', child_results[0].xml)
880 if shape_match:
881 ctx.anim_targets.append((int(shape_match.group(1)), elem_id))
882 return child_results[0]
883
884 # Multiple children, or a top-level semantic one-child group: wrap in
885 # <p:grpSp> so PowerPoint can animate the group as one unit.
886 min_x = min_y = float('inf')
887 max_x = max_y = float('-inf')
888
889 for child_result in child_results:
890 bounds = child_result.bounds_emu
891 if bounds is None:
892 continue
893 min_x = min(min_x, bounds[0])
894 min_y = min(min_y, bounds[1])
895 max_x = max(max_x, bounds[2])
896 max_y = max(max_y, bounds[3])
897
898 if min_x == float('inf'):
899 return ShapeResult(xml='\n'.join(result.xml for result in child_results))
900
901 group_x = int(min_x)
902 group_y = int(min_y)
903 group_w = max(int(max_x - min_x), 1)
904 group_h = max(int(max_y - min_y), 1)
905
906 # ``rotate(angle, cx, cy)`` rotates around the SVG pivot, but DrawingML
907 # grpSp ``rot`` always rotates around the group's own bbox centre. When
908 # those centres differ, the visual position drifts by exactly the
909 # translation a rotate-around-pivot equals. Compensate by offsetting the
910 # outer <a:off> only; <a:chOff> stays on the unshifted bbox so children
911 # (still at their original SVG positions because rotate_pivot suppressed
912 # the dx/dy translation above) remain aligned inside the group.
913 off_x = group_x
914 off_y = group_y
915 if rotate_pivot is not None and angle_deg:
916 cx_svg, cy_svg = rotate_pivot
917 pivot_ex = (cx_svg + ctx.translate_x) * EMU_PER_PX
918 pivot_ey = (cy_svg + ctx.translate_y) * EMU_PER_PX
919 bbox_cx = group_x + group_w / 2
920 bbox_cy = group_y + group_h / 2
921 theta = math.radians(angle_deg)
922 cos_t = math.cos(theta)
923 sin_t = math.sin(theta)
924 # Where the bbox centre lands after rotating around the pivot, minus
925 # where DrawingML's grpSp rot would leave it (i.e. unchanged).
926 delta_x = (bbox_cx - pivot_ex) * cos_t - (bbox_cy - pivot_ey) * sin_t + pivot_ex - bbox_cx
927 delta_y = (bbox_cx - pivot_ex) * sin_t + (bbox_cy - pivot_ey) * cos_t + pivot_ey - bbox_cy
928 off_x = int(round(group_x + delta_x))
929 off_y = int(round(group_y + delta_y))
930
931 shapes_xml = '\n'.join(result.xml for result in child_results)
932 group_id = (
933 ctx.claim_shape_id(
934 elem.get('data-pptx-shape-id'),
935 elem.get('data-pptx-shape-scope'),
936 )
937 if elem.get('data-pptx-object') == 'group'
938 else ctx.next_id()
939 )
940
941 # Record top-level semantic groups (e.g. <g id="p02-title">) so the
942 # PPTX builder can emit per-element object timing. Only the outermost
943 # multi-child wrapper qualifies — flattened single-child groups have no
944 # <p:grpSp> to anchor a timing target on, and nested groups are
945 # ignored to keep the animation budget at ~per-section granularity.
946 if should_animate_group:
947 ctx.anim_targets.append((group_id, elem_id))
948
949 group_effect = ''
950 if filter_id and filter_id in ctx.defs:
951 group_effect = build_effect_xml(
952 ctx.defs[filter_id],
953 child_ctx.opacity_multiplier,
954 )
955
956 rot_emu = 0 if matrix_supported else int(angle_deg * 60000)
957 rot_attr = f' rot="{rot_emu}"' if rot_emu else ''
958 validate_ooxml_xfrm(off_x, off_y, group_w, group_h)
959 validate_ooxml_xfrm(group_x, group_y, group_w, group_h)
960
961 return ShapeResult(xml=f'''<p:grpSp>
962 <p:nvGrpSpPr>
963 <p:cNvPr id="{group_id}" name="Group {group_id}"/>
964 <p:cNvGrpSpPr/>
965 <p:nvPr/>
966 </p:nvGrpSpPr>
967 <p:grpSpPr>
968 <a:xfrm{rot_attr}>
969 <a:off x="{off_x}" y="{off_y}"/>
970 <a:ext cx="{group_w}" cy="{group_h}"/>
971 <a:chOff x="{group_x}" y="{group_y}"/>
972 <a:chExt cx="{group_w}" cy="{group_h}"/>
973 </a:xfrm>
974 {group_effect}
975 </p:grpSpPr>
976 {shapes_xml}
977 </p:grpSp>''', bounds_emu=(group_x, group_y, group_x + group_w, group_y + group_h))
978
979
980 # ---------------------------------------------------------------------------
981 # Defs collection & element dispatch
982 # ---------------------------------------------------------------------------
983
984 _NON_VISUAL_TAGS = frozenset(('defs', 'title', 'desc', 'metadata', 'style'))
985
986 _CONVERTERS = {
987 'rect': convert_rect,
988 'circle': convert_circle,
989 'ellipse': convert_ellipse,
990 'line': convert_line,
991 'path': convert_path,
992 'polygon': convert_polygon,
993 'polyline': convert_polyline,
994 'text': convert_text,
995 'image': convert_image,
996 'g': convert_g,
997 'svg': convert_nested_svg,
998 }
999
1000 _SUPPORTED_VISUAL_CHILD_TAGS = frozenset(('tspan',))
1001
1002
1003 def _parse_svg_canvas(root: ET.Element) -> tuple[float, float, float, float]:
1004 """Return the SVG canvas as (x, y, width, height) in SVG units."""
1005 viewbox = parse_project_viewbox(root.get('viewBox'))
1006 return 0.0, 0.0, float(viewbox.width), float(viewbox.height)
1007
1008
1009 def _is_full_canvas_rect(
1010 elem: ET.Element,
1011 ctx: ConvertContext,
1012 canvas: tuple[float, float, float, float],
1013 ) -> bool:
1014 """Return whether a rect is a safe candidate for native slide background."""
1015 if elem.get('transform') or elem.get('filter') or elem.get('clip-path'):
1016 return False
1017 if any(
1018 elem.get(attr) is not None
1019 for attr in (
1020 'data-pptx-object',
1021 'data-pptx-prst',
1022 'data-pptx-frame',
1023 'data-pptx-geometry-status',
1024 )
1025 ):
1026 return False
1027 if (
1028 parse_svg_length(elem.get('rx'), 0.0) > 0
1029 or parse_svg_length(elem.get('ry'), 0.0) > 0
1030 ):
1031 return False
1032
1033 canvas_x, canvas_y, canvas_w, canvas_h = canvas
1034 if canvas_w <= 0 or canvas_h <= 0:
1035 return False
1036
1037 tolerance = 0.5
1038 if abs(parse_svg_length(elem.get('x'), 0.0) - canvas_x) > tolerance:
1039 return False
1040 if abs(parse_svg_length(elem.get('y'), 0.0) - canvas_y) > tolerance:
1041 return False
1042 if abs(parse_svg_length(elem.get('width'), 0.0) - canvas_w) > tolerance:
1043 return False
1044 if abs(parse_svg_length(elem.get('height'), 0.0) - canvas_h) > tolerance:
1045 return False
1046
1047 fill = _get_attr(elem, 'fill', ctx)
1048 if fill == 'none':
1049 return False
1050
1051 stroke = _get_attr(elem, 'stroke', ctx)
1052 stroke_width = parse_svg_length(_get_attr(elem, 'stroke-width', ctx), 1.0)
1053 stroke_opacity = get_stroke_opacity(elem, ctx)
1054 if stroke and stroke != 'none' and stroke_width > 0 and stroke_opacity != 0:
1055 return False
1056
1057 return True
1058
1059
1060 def _background_xml_from_rect(
1061 elem: ET.Element,
1062 ctx: ConvertContext,
1063 ) -> str:
1064 """Build native ``p:bg`` XML from a full-slide SVG background rect."""
1065 fill_xml = build_fill_xml(
1066 elem,
1067 ctx,
1068 get_fill_opacity(elem, ctx),
1069 usage="background",
1070 )
1071 if not fill_xml or '<a:noFill' in fill_xml:
1072 return ''
1073 return f'<p:bg><p:bgPr>{fill_xml}<a:effectLst/></p:bgPr></p:bg>'
1074
1075
1076 def _extract_background_candidate(
1077 root: ET.Element,
1078 ctx: ConvertContext,
1079 ) -> tuple[str, int | None]:
1080 """Promote a first-layer SVG background rect to native PowerPoint bgPr.
1081
1082 PowerPoint stores page background fills under ``p:cSld/p:bg/p:bgPr``.
1083 Keeping the full-canvas SVG rect in ``p:spTree`` makes it an ordinary
1084 selectable shape, so users hit it during bulk element selection. Only the
1085 first visual layer is considered, matching pptx_to_svg's round-trip output
1086 and avoiding accidental promotion of content panels.
1087 """
1088 canvas = _parse_svg_canvas(root)
1089 for child in root:
1090 tag = child.tag.replace(f'{{{SVG_NS}}}', '')
1091 if tag in _NON_VISUAL_TAGS:
1092 continue
1093
1094 if tag == 'rect' and _is_full_canvas_rect(child, ctx, canvas):
1095 bg_xml = _background_xml_from_rect(child, ctx)
1096 if bg_xml:
1097 return bg_xml, id(child)
1098 return '', None
1099
1100 if tag != 'g':
1101 return '', None
1102 if child.get('transform') or child.get('filter') or child.get('clip-path'):
1103 return '', None
1104 style_overrides = _extract_inheritable_styles(child)
1105 local_opacity = get_element_opacity(child)
1106 child_ctx = ctx.child(
1107 style_overrides=style_overrides,
1108 opacity_multiplier=1.0 if local_opacity is None else local_opacity,
1109 )
1110 visual_children = [
1111 grandchild for grandchild in child
1112 if grandchild.tag.replace(f'{{{SVG_NS}}}', '') not in _NON_VISUAL_TAGS
1113 ]
1114 if len(visual_children) != 1:
1115 return '', None
1116 only_child = visual_children[0]
1117 only_tag = only_child.tag.replace(f'{{{SVG_NS}}}', '')
1118 if only_tag == 'rect' and _is_full_canvas_rect(only_child, child_ctx, canvas):
1119 bg_xml = _background_xml_from_rect(only_child, child_ctx)
1120 if bg_xml:
1121 ctx.sync_from_child(child_ctx)
1122 return bg_xml, id(child)
1123 return '', None
1124 return '', None
1125
1126 return '', None
1127
1128
1129 def collect_defs(root: ET.Element) -> dict[str, ET.Element]:
1130 """Collect all <defs> children into an {id: element} dictionary."""
1131 definitions, _duplicates = project_definition_index(root)
1132 return definitions
1133
1134
1135 def _build_source_shape_id_map(root: ET.Element) -> dict[tuple[str, str], int]:
1136 """Allocate page-unique ids for part-scoped imported shape identities."""
1137 source_entries: list[tuple[tuple[str, str], int]] = []
1138 seen_keys: set[tuple[str, str]] = set()
1139 for elem in root.iter():
1140 raw_id = elem.get('data-pptx-shape-id')
1141 if raw_id is None:
1142 continue
1143 scope = elem.get('data-pptx-shape-scope') or 'slide'
1144 if re.fullmatch(r'[A-Za-z0-9_.-]{1,64}', scope) is None:
1145 raise SvgNativeConversionError(
1146 f'Invalid data-pptx-shape-scope {scope!r}'
1147 )
1148 try:
1149 shape_id = int(raw_id)
1150 except ValueError as exc:
1151 raise SvgNativeConversionError(
1152 f'Invalid data-pptx-shape-id {raw_id!r}'
1153 ) from exc
1154 if shape_id < 2 or shape_id > 0xFFFFFFFF:
1155 raise SvgNativeConversionError(
1156 f'data-pptx-shape-id must be between 2 and 4294967295, got {raw_id!r}'
1157 )
1158 key = (scope, raw_id)
1159 if key in seen_keys:
1160 continue
1161 seen_keys.add(key)
1162 source_entries.append((key, shape_id))
1163
1164 preferred_ids = {shape_id for _key, shape_id in source_entries}
1165 next_fresh = max(preferred_ids, default=1) + 1
1166 used: set[int] = set()
1167 mapping: dict[tuple[str, str], int] = {}
1168 for key, preferred in source_entries:
1169 output_id = preferred
1170 if output_id in used:
1171 while next_fresh in preferred_ids or next_fresh in used:
1172 next_fresh += 1
1173 if next_fresh > 0xFFFFFFFF:
1174 raise SvgNativeConversionError('Exhausted PowerPoint shape id range')
1175 output_id = next_fresh
1176 next_fresh += 1
1177 used.add(output_id)
1178 mapping[key] = output_id
1179 return mapping
1180
1181
1182 def _geometry_trace_metadata(elem: ET.Element, result: ShapeResult) -> dict[str, Any]:
1183 """Describe the native geometry decision for conversion diagnostics."""
1184 xml = result.xml.lstrip()
1185 if xml.startswith('<p:grpSp>'):
1186 return {'output_geometry': 'group', 'fidelity': 'visual-only'}
1187 if xml.startswith('<p:pic>'):
1188 return {'output_geometry': 'picture', 'fidelity': 'native-normalized'}
1189 if xml.startswith('<p:graphicFrame>'):
1190 return {'output_geometry': 'native-object', 'fidelity': 'native-normalized'}
1191
1192 preset_match = re.search(r'<a:prstGeom prst="([^"]+)"', xml)
1193 if preset_match is not None:
1194 preset = preset_match.group(1)
1195 source_preset = elem.get('data-pptx-prst')
1196 is_connector = xml.startswith('<p:cxnSp>')
1197 fidelity = (
1198 'exact'
1199 if source_preset == preset
1200 and elem.get('data-pptx-frame') is not None
1201 and not is_connector
1202 else 'native-normalized'
1203 )
1204 return {
1205 'output_geometry': 'preset',
1206 'preset': preset,
1207 'fidelity': fidelity,
1208 }
1209 if re.search(r'<a:custGeom(?:\s|>)', xml):
1210 carrier = next(
1211 (
1212 candidate
1213 for candidate in elem.iter()
1214 if candidate.get('data-pptx-part') == 'geometry'
1215 ),
1216 elem,
1217 )
1218 source_custom = (
1219 carrier.get('data-pptx-geometry-kind') == 'custom'
1220 and carrier.get('data-pptx-frame') is not None
1221 )
1222 expected_hash = carrier.get('data-pptx-geometry-sha256')
1223 actual_hash = hashlib.sha256(
1224 (carrier.get('d') or '').strip().encode('utf-8')
1225 ).hexdigest()
1226 unchanged = source_custom and expected_hash == actual_hash
1227 if unchanged:
1228 fidelity = 'exact'
1229 geometry_source = 'preserved-metadata'
1230 elif source_custom:
1231 fidelity = 'native-normalized'
1232 geometry_source = 'svg-recompiled'
1233 else:
1234 fidelity = 'visual-only'
1235 geometry_source = 'svg-authored'
1236 return {
1237 'output_geometry': 'custom',
1238 'fidelity': fidelity,
1239 'geometry_source': geometry_source,
1240 }
1241 return {'output_geometry': 'unknown', 'fidelity': 'visual-only'}
1242
1243
1244 def convert_element(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
1245 """Dispatch an SVG element to the appropriate converter."""
1246 tag = elem.tag.replace(f'{{{SVG_NS}}}', '')
1247 elem_id = elem.get('id')
1248
1249 def trace(decision: str, **metadata: Any) -> None:
1250 if ctx.trace_events is None:
1251 return
1252 event: dict[str, Any] = {
1253 'tag': tag,
1254 'decision': decision,
1255 }
1256 if elem_id:
1257 event['id'] = elem_id
1258 for attr in (
1259 'data-pptx-layer',
1260 'data-pptx-object',
1261 'data-pptx-shape-id',
1262 'data-pptx-frame',
1263 'data-pptx-prst',
1264 'data-pptx-part',
1265 'data-pptx-geometry-status',
1266 'data-pptx-geometry-reason',
1267 'data-pptx-placeholder',
1268 'data-pptx-bounds',
1269 'data-pptx-carrier',
1270 'data-pptx-idx',
1271 'data-pptx-role',
1272 ):
1273 value = elem.get(attr)
1274 if value is not None:
1275 event[attr] = value
1276 adjustments = {
1277 attr[len('data-pptx-av-'):]: value
1278 for attr, value in elem.attrib.items()
1279 if attr.startswith('data-pptx-av-')
1280 }
1281 if adjustments:
1282 event['adjustments'] = dict(sorted(adjustments.items()))
1283 event.update(metadata)
1284 ctx.trace_events.append(event)
1285
1286 if elem.get('data-pptx-part') == 'geometry-detail':
1287 trace('skip', reason='render-only-preset-geometry-detail')
1288 return None
1289
1290 converter = _CONVERTERS.get(tag)
1291 if converter:
1292 try:
1293 result = converter(elem, ctx)
1294 except Exception as e:
1295 trace('error', error=str(e))
1296 raise SvgNativeConversionError(f'Failed to convert <{tag}>: {e}') from e
1297 if result:
1298 shape_match = re.search(r'<p:cNvPr id="(\d+)"', result.xml)
1299 metadata: dict[str, Any] = {}
1300 if shape_match:
1301 metadata['shape_id'] = int(shape_match.group(1))
1302 if result.bounds_emu is not None:
1303 metadata['bounds_emu'] = list(result.bounds_emu)
1304 metadata.update(_geometry_trace_metadata(elem, result))
1305 trace('native', **metadata)
1306 else:
1307 trace('skip', reason='empty-or-non-rendering')
1308 return result
1309
1310 if tag in _NON_VISUAL_TAGS:
1311 trace('skip', reason='non-visual')
1312 return None
1313
1314 trace('unsupported')
1315 raise SvgNativeConversionError(f'Unsupported visual SVG element <{tag}>')
1316
1317
1318 def _local_tag(elem: ET.Element) -> str:
1319 if not isinstance(elem.tag, str):
1320 return str(elem.tag)
1321 prefix = f'{{{SVG_NS}}}'
1322 return elem.tag[len(prefix):] if elem.tag.startswith(prefix) else elem.tag
1323
1324
1325 def collect_unsupported_visuals(
1326 root: ET.Element,
1327 *,
1328 allow_data_icon_use: bool = False,
1329 ) -> list[str]:
1330 """Return visual element paths that the native converter cannot dispatch."""
1331 issues: list[str] = []
1332
1333 def walk(
1334 elem: ET.Element,
1335 path: str,
1336 in_defs: bool = False,
1337 parent_tag: str | None = None,
1338 ) -> None:
1339 tag = _local_tag(elem)
1340 current = f'{path}/{tag}'
1341 if in_defs:
1342 return
1343 if tag in _NON_VISUAL_TAGS:
1344 return
1345 is_supported_visual_child = (
1346 tag in _SUPPORTED_VISUAL_CHILD_TAGS
1347 and parent_tag in {'text', 'tspan'}
1348 )
1349 is_data_icon_placeholder = (
1350 allow_data_icon_use
1351 and tag == 'use'
1352 and elem.get('data-icon') is not None
1353 )
1354 if (tag not in _CONVERTERS
1355 and tag not in _NON_VISUAL_TAGS
1356 and not is_supported_visual_child
1357 and not is_data_icon_placeholder):
1358 issues.append(current)
1359 for idx, child in enumerate(list(elem), start=1):
1360 walk(
1361 child,
1362 f'{current}[{idx}]',
1363 in_defs=(tag == 'defs'),
1364 parent_tag=tag,
1365 )
1366
1367 for idx, child in enumerate(list(root), start=1):
1368 walk(child, f'/svg[{idx}]', parent_tag='svg')
1369 return issues
1370
1371
1372 def convert_svg_to_slide_shapes(
1373 svg_path: str | Path,
1374 slide_num: int = 1,
1375 verbose: bool = False,
1376 merge_paragraphs: bool | None = None,
1377 image_optimize: bool = True,
1378 image_max_dimension: int | None = 2560,
1379 image_sizing: str = 'cap',
1380 image_scale: float = 2.0,
1381 image_quality: int = 85,
1382 native_objects: bool = False,
1383 animation_group_overrides: frozenset[str] | None = None,
1384 theme_font_spec: ThemeFontSpec | None = None,
1385 theme_color_spec: ThemeColorSpec | None = None,
1386 primary_language: str | None = None,
1387 trace_out: list[dict[str, Any]] | None = None,
1388 promote_background: bool = True,
1389 text_flow: str | None = None,
1390 ) -> tuple[
1391 str,
1392 dict[str, bytes],
1393 list[dict[str, str]],
1394 list,
1395 dict[str, bytes],
1396 dict[str, str],
1397 ]:
1398 """Convert an SVG file to a complete DrawingML slide XML.
1399
1400 Args:
1401 svg_path: Path to the SVG file.
1402 slide_num: Slide number (for naming).
1403 verbose: Print progress info.
1404 merge_paragraphs: Legacy compatibility option. True selects reflow;
1405 False selects split. Do not combine with ``text_flow``.
1406 text_flow: Positional-tspan policy. ``preserve`` keeps authored visual
1407 line breaks in one frame, ``reflow`` lets PowerPoint wrap the text,
1408 and ``split`` emits one text frame per visual line.
1409 image_optimize: Downsample oversized raster images for PPTX export.
1410 image_max_dimension: Maximum optimized image dimension in pixels.
1411 image_sizing: ``cap`` to only cap source dimensions, ``display`` to
1412 size from rendered SVG boxes.
1413 image_scale: Target image pixels per SVG display pixel.
1414 image_quality: JPEG quality used for opaque optimized rasters.
1415 native_objects: Convert explicit ``data-pptx-replace-with`` chart/table
1416 markers to native PowerPoint Chart/Table objects. Default off.
1417 animation_group_overrides: Explicit top-level SVG group ids from
1418 ``animations.json`` that override the legacy chrome-name fallback.
1419 Explicit structural layer/role/placeholder markers remain excluded.
1420 theme_font_spec: Optional major/minor theme-font contract. Matching SVG
1421 families emit DrawingML theme tokens instead of fixed typefaces.
1422 theme_color_spec: Optional context-aware theme-color contract. Exact
1423 locked colors emit DrawingML scheme tokens while local colors stay
1424 fixed.
1425 primary_language: Canonical BCP-47 project content language. ``None``
1426 keeps the legacy per-run script heuristic.
1427 trace_out: Optional list populated with one per-slide trace dictionary.
1428 promote_background: Promote the first eligible full-canvas rectangle
1429 into native ``p:bg``. Structured export disables this generic pass
1430 and applies its narrower explicit background contract later.
1431
1432 Returns:
1433 (slide_xml, media_files, rel_entries, anim_targets,
1434 package_files, content_type_overrides) where:
1435 - slide_xml: Complete slide XML string.
1436 - media_files: Dict of {filename: bytes} for media to write.
1437 - rel_entries: List of relationship entries to add.
1438 - anim_targets: List of (shape_id, svg_id) tuples for top-level
1439 semantic groups, in z-order; consumed by the builder's optional
1440 per-element object-animation timing emitter.
1441 - package_files: Dict of {pptx internal path: bytes} for non-media
1442 OOXML parts such as native chart XML and embedded workbooks.
1443 - content_type_overrides: Dict of {pptx internal path: content type}
1444 for package_files that require [Content_Types].xml overrides.
1445 """
1446 text_flow = resolve_text_flow(text_flow, merge_paragraphs)
1447 svg_path = Path(svg_path)
1448 tree = ET.parse(str(svg_path))
1449 root = tree.getroot()
1450 _hydrate_native_payloads(root, svg_path)
1451 try:
1452 parse_project_svg_root(
1453 root,
1454 context=svg_path.name,
1455 )
1456 except CanvasContractError as exc:
1457 raise SvgNativeConversionError(str(exc)) from exc
1458 _require_chart_table_marker_attributes(root, svg_path)
1459 _require_project_nested_svg_crops(root, svg_path)
1460 _require_project_clip_paths(root, svg_path)
1461 authored_errors = validate_authored_preset_tree(root)
1462 if authored_errors:
1463 raise SvgNativeConversionError(
1464 'Invalid authored preset structure: ' + '; '.join(authored_errors)
1465 )
1466 # Validate the source contract once, then lower compact groups to the
1467 # established expanded transport IR. Downstream conversion validates the
1468 # generated preview hash, not the source-format allowlist again.
1469 try:
1470 materialize_compact_authored_preset_tree(root)
1471 except ValueError as exc:
1472 raise SvgNativeConversionError(
1473 f'Invalid compact authored preset: {exc}'
1474 ) from exc
1475 _mark_unchanged_txbody_groups(root)
1476 _mark_unchanged_preset_previews(root)
1477 if native_objects:
1478 try:
1479 snapshot_native_fallback_freshness(root)
1480 except NativeMarkerAttributeError as exc:
1481 raise SvgNativeConversionError(
1482 f'{Path(svg_path).name}: conflicting chart/table replacement '
1483 f'metadata: {exc}'
1484 ) from exc
1485 trace_events: list[dict[str, Any]] | None = [] if trace_out is not None else None
1486 trace_steps: list[dict[str, Any]] = []
1487
1488 from ..geometry_properties import (
1489 GeometryStyleError,
1490 materialize_inline_geometry_properties,
1491 )
1492
1493 try:
1494 geometry_count = materialize_inline_geometry_properties(root)
1495 except GeometryStyleError as exc:
1496 raise SvgNativeConversionError(
1497 f'{svg_path.name}: inline geometry materialization failed: {exc}'
1498 ) from exc
1499 geometry_trace = None
1500 if geometry_count:
1501 geometry_trace = {
1502 'action': 'materialize-inline-geometry',
1503 'count': geometry_count,
1504 }
1505 trace_steps.append(geometry_trace)
1506 if verbose:
1507 print(f' Materialized {geometry_count} inline geometry declaration(s)')
1508
1509 geometry_length_errors = project_geometry_length_errors(root)
1510 if geometry_length_errors:
1511 preview = '; '.join(geometry_length_errors[:8])
1512 suffix = (
1513 '' if len(geometry_length_errors) <= 8
1514 else f'; +{len(geometry_length_errors) - 8} more'
1515 )
1516 raise SvgNativeConversionError(
1517 f'{Path(svg_path).name}: invalid project geometry length(s): '
1518 f'{preview}{suffix}'
1519 )
1520
1521 _require_project_text_properties(root, svg_path)
1522 _require_project_freeform_geometry(root, svg_path)
1523 _require_project_stroke_styles(root, svg_path)
1524 _require_project_opacities(root, svg_path)
1525 _require_project_paints(root, svg_path)
1526 _require_project_masks(root, svg_path)
1527 _require_project_definitions(root, svg_path)
1528 _require_project_paint_references(root, svg_path)
1529 _require_project_line_end_markers(root, svg_path)
1530 _require_project_gradients(root, svg_path)
1531 _require_project_effect_status(root, svg_path)
1532 _require_project_filters(root, svg_path)
1533 _require_project_image_aspect_ratios(root, svg_path)
1534 _require_project_transforms(root, svg_path)
1535
1536 viewport_width, viewport_height = _root_viewport_size(root)
1537
1538 # Expand project icon placeholders and static same-document <use>
1539 # references before unsupported-element preflight.
1540 from ..use_expander import (
1541 UseExpansionError,
1542 expand_local_use_references,
1543 expand_use_data_icons,
1544 )
1545
1546 icons_dir, icons_fallback_dir = icon_search_dirs_for_svg(svg_path)
1547 if icons_dir.exists():
1548 expanded = expand_use_data_icons(root, icons_dir, icons_fallback_dir)
1549 if expanded:
1550 trace_steps.append({'action': 'expand-use-data-icons', 'count': expanded})
1551 if verbose and expanded:
1552 print(f' Expanded {expanded} <use data-icon="..."/> placeholder(s)')
1553 if expanded:
1554 hydrated = _hydrate_native_payloads(root, svg_path)
1555 if hydrated:
1556 trace_steps.append({
1557 'action': 'hydrate-native-payloads-from-icons',
1558 'count': hydrated,
1559 })
1560 _mark_unchanged_txbody_groups(root)
1561 _mark_unchanged_preset_previews(root)
1562 _require_project_freeform_geometry(root, svg_path)
1563
1564 try:
1565 injected_geometry_count = materialize_inline_geometry_properties(root)
1566 except GeometryStyleError as exc:
1567 raise SvgNativeConversionError(
1568 f'{svg_path.name}: expanded icon geometry materialization failed: {exc}'
1569 ) from exc
1570 if injected_geometry_count:
1571 geometry_count += injected_geometry_count
1572 if geometry_trace is None:
1573 geometry_trace = {
1574 'action': 'materialize-inline-geometry',
1575 'count': geometry_count,
1576 }
1577 trace_steps.append(geometry_trace)
1578 else:
1579 geometry_trace['count'] = geometry_count
1580 if verbose:
1581 print(
1582 f' Materialized {injected_geometry_count} inline geometry '
1583 'declaration(s) from expanded icons'
1584 )
1585
1586 try:
1587 expanded_local = expand_local_use_references(root)
1588 except UseExpansionError as exc:
1589 raise SvgNativeConversionError(
1590 f'{svg_path.name}: local <use> expansion failed: {exc}'
1591 ) from exc
1592 if expanded_local:
1593 trace_steps.append({
1594 'action': 'expand-local-use-references',
1595 'count': expanded_local,
1596 })
1597 if verbose:
1598 print(f' Expanded {expanded_local} local <use href="#..."/> instance(s)')
1599
1600 # Recheck compiler-injected icon/use wrappers and cloned definition trees.
1601 _require_project_nested_svg_crops(root, svg_path)
1602 _require_project_images(root, svg_path)
1603 _require_project_clip_paths(root, svg_path)
1604 _require_project_text_properties(root, svg_path)
1605 _require_project_stroke_styles(root, svg_path)
1606 _require_project_opacities(root, svg_path)
1607 _require_project_paints(root, svg_path)
1608 _require_project_masks(root, svg_path)
1609 _require_project_definitions(root, svg_path)
1610 _require_project_paint_references(root, svg_path)
1611 _require_project_gradients(root, svg_path)
1612 _require_project_effect_status(root, svg_path)
1613 _require_project_filters(root, svg_path)
1614 _require_project_image_aspect_ratios(root, svg_path)
1615 _require_project_transforms(root, svg_path)
1616
1617 try:
1618 materialize_project_text_metrics(root)
1619 except ValueError as exc:
1620 raise SvgNativeConversionError(
1621 f'{svg_path.name}: text-metric materialization failed: {exc}'
1622 ) from exc
1623
1624 # Flatten positional <tspan> (those with x/y/non-zero dy) into independent
1625 # <text> elements. DrawingML runs cannot reposition mid-paragraph, so a
1626 # dy-stacked block of tspans would otherwise collapse onto one baseline,
1627 # and an x-anchored tspan would render in the wrong column. finalize_svg
1628 # does the same flattening on disk; doing it here keeps native pptx output
1629 # correct when reading raw svg_output/.
1630 # Preserve/reflow modes additionally fold conservative paragraph blocks
1631 # into one annotated <text>. Preserve keeps visual lines as DrawingML hard
1632 # breaks; reflow joins wrapping lines; split keeps one frame per line.
1633 from ..tspan_flattener import flatten_positional_tspans
1634 flattened = flatten_positional_tspans(
1635 tree,
1636 merge_paragraphs=text_flow != TEXT_FLOW_SPLIT,
1637 preserve_line_breaks=text_flow == TEXT_FLOW_PRESERVE,
1638 )
1639 if flattened:
1640 trace_steps.append({
1641 'action': 'flatten-positional-tspans',
1642 'text_flow': text_flow,
1643 # Compatibility field for older trace readers.
1644 'merge_paragraphs': text_flow != TEXT_FLOW_SPLIT,
1645 })
1646 if verbose:
1647 print(f' Lowered positional <tspan> using {text_flow} text flow')
1648
1649 _require_project_text_properties(root, svg_path)
1650 try:
1651 text_font_sizes = resolve_project_font_sizes(root)
1652 text_letter_spacings = resolve_project_letter_spacings(
1653 root,
1654 text_font_sizes,
1655 )
1656 except ValueError as exc:
1657 raise SvgNativeConversionError(
1658 f'{svg_path.name}: invalid project text-metric inheritance: {exc}'
1659 ) from exc
1660
1661 unsupported = collect_unsupported_visuals(root)
1662 if unsupported:
1663 preview = '; '.join(unsupported[:8])
1664 suffix = '' if len(unsupported) <= 8 else f'; +{len(unsupported) - 8} more'
1665 raise SvgNativeConversionError(
1666 f'{svg_path.name}: unsupported visual SVG element(s): {preview}{suffix}'
1667 )
1668
1669 defs = collect_defs(root)
1670 source_shape_id_map = _build_source_shape_id_map(root)
1671 ctx = ConvertContext(
1672 defs=defs,
1673 reserved_shape_ids=frozenset(source_shape_id_map.values()),
1674 source_shape_id_map=source_shape_id_map,
1675 slide_num=slide_num,
1676 viewport_width=viewport_width,
1677 viewport_height=viewport_height,
1678 svg_dir=Path(svg_path).parent,
1679 text_flow=text_flow,
1680 image_optimize=image_optimize,
1681 image_max_dimension=image_max_dimension,
1682 image_sizing=image_sizing,
1683 image_scale=image_scale,
1684 image_quality=image_quality,
1685 native_objects_enabled=native_objects,
1686 animation_group_overrides=animation_group_overrides or frozenset(),
1687 trace_events=trace_events,
1688 theme_font_spec=theme_font_spec,
1689 theme_color_spec=theme_color_spec,
1690 primary_language=primary_language,
1691 inherited_styles=_extract_inheritable_styles(root),
1692 text_font_sizes=text_font_sizes,
1693 text_letter_spacings=text_letter_spacings,
1694 )
1695
1696 shapes: list[str] = []
1697 converted = 0
1698 skipped = 0
1699 has_top_level_group = any(
1700 child.tag.replace(f'{{{SVG_NS}}}', '') == 'g'
1701 for child in root
1702 )
1703 background_xml, background_skip_id = (
1704 _extract_background_candidate(root, ctx)
1705 if promote_background
1706 else ('', None)
1707 )
1708 promoted_backgrounds = 1 if background_xml else 0
1709 if background_xml and trace_events is not None:
1710 trace_events.append({
1711 'tag': 'rect',
1712 'decision': 'native-background',
1713 'reason': 'promoted-full-canvas-rect-to-bgPr',
1714 })
1715 # Per-element shape ids of every top-level child, used as an animation
1716 # fallback when no <g id="..."> groups are present at the root.
1717 fallback_targets: list = []
1718
1719 for child in root:
1720 tag = child.tag.replace(f'{{{SVG_NS}}}', '')
1721 if tag == 'defs':
1722 continue
1723 if id(child) == background_skip_id:
1724 continue
1725 result = convert_element(child, ctx)
1726 if result:
1727 shapes.append(result.xml)
1728 converted += 1
1729 m = re.search(r'<p:cNvPr id="(\d+)"', result.xml)
1730 elem_id = child.get('id')
1731 role = child.get('data-pptx-role')
1732 placeholder = child.get('data-pptx-placeholder')
1733 has_explicit_semantics = role is not None or placeholder is not None
1734 structurally_static = (
1735 child.get('data-pptx-layer') is not None
1736 or (
1737 has_explicit_semantics
1738 and is_static_page_frame(role, placeholder)
1739 )
1740 )
1741 legacy_chrome = (
1742 not has_explicit_semantics
1743 and is_chrome_id(elem_id)
1744 )
1745 explicit_legacy_override = (
1746 elem_id is not None
1747 and elem_id in ctx.animation_group_overrides
1748 )
1749 if (
1750 m
1751 and not structurally_static
1752 and (not legacy_chrome or explicit_legacy_override)
1753 ):
1754 fallback_targets.append((int(m.group(1)), elem_id or tag))
1755 else:
1756 if tag not in _NON_VISUAL_TAGS:
1757 skipped += 1
1758
1759 unresolved_connector_targets = sorted(
1760 ctx.referenced_shape_ids - ctx.claimed_shape_ids
1761 )
1762 if unresolved_connector_targets:
1763 raise SvgNativeConversionError(
1764 'Connector target shape ids were reserved but not restored: '
1765 + ', '.join(str(shape_id) for shape_id in unresolved_connector_targets)
1766 )
1767
1768 # Animation target fallback. Semantic <g id="..."> groups are the
1769 # preferred anchors (set inside convert_g). When the SVG has none
1770 # at the root we fall back to top-level primitives, but only when
1771 # the count is reasonable. Presenter-click animation should reveal
1772 # semantic blocks, not atomized drawing primitives, so fallback is
1773 # intentionally capped at a low count.
1774 _ANIM_FALLBACK_CAP = 8
1775 if (
1776 not has_top_level_group
1777 and not ctx.anim_targets
1778 and 0 < len(fallback_targets) <= _ANIM_FALLBACK_CAP
1779 ):
1780 ctx.anim_targets = fallback_targets
1781
1782 if verbose:
1783 promoted = (
1784 f', promoted {promoted_backgrounds} background'
1785 if promoted_backgrounds else ''
1786 )
1787 print(f' Converted {converted} elements, skipped {skipped}{promoted}')
1788
1789 if trace_out is not None:
1790 trace_out.append({
1791 'slide_num': slide_num,
1792 'svg': str(svg_path),
1793 'page_role': root.get('data-pptx-page-role'),
1794 'summary': {
1795 'converted': converted,
1796 'skipped': skipped,
1797 'promoted_backgrounds': promoted_backgrounds,
1798 'media_files': len(ctx.media_files),
1799 'package_files': len(ctx.package_files),
1800 'relationships': len(ctx.rel_entries),
1801 'animation_targets': len(ctx.anim_targets),
1802 },
1803 'preprocess': trace_steps,
1804 'events': trace_events or [],
1805 })
1806
1807 shapes_xml = '\n'.join(shapes)
1808
1809 slide_xml = f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1810 <p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
1811 xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
1812 xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
1813 <p:cSld>
1814 {background_xml}
1815 <p:spTree>
1816 <p:nvGrpSpPr>
1817 <p:cNvPr id="1" name=""/>
1818 <p:cNvGrpSpPr/><p:nvPr/>
1819 </p:nvGrpSpPr>
1820 <p:grpSpPr>
1821 <a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/>
1822 <a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm>
1823 </p:grpSpPr>
1824 {shapes_xml}
1825 </p:spTree>
1826 </p:cSld>
1827 <p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr>
1828 </p:sld>'''
1829
1830 return (
1831 slide_xml,
1832 ctx.media_files,
1833 ctx.rel_entries,
1834 ctx.anim_targets,
1835 ctx.package_files,
1836 ctx.content_type_overrides,
1837 )
1838
1838 lines PYTHON