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