"""Core SVG -> DrawingML dispatcher, group handling, and main entry point.""" from __future__ import annotations import base64 import binascii import hashlib import math import re from pathlib import Path from typing import Any from xml.etree import ElementTree as ET from native_payloads import NativePayloadError, hydrate_native_payload_refs from pptx_shapes import ( has_relationship_attributes, resolve_preset_preview_hash, svg_preset_preview_fingerprint, svg_text_fingerprint, validate_ooxml_xfrm, ) from pptx_effects import project_effect_status_errors, txbody_has_run_effects from pptx_to_svg.preset_authoring import ( materialize_compact_authored_preset_tree, validate_authored_preset_tree, ) from resource_paths import icon_search_dirs_for_svg from .context import ( TEXT_FLOW_PRESERVE, TEXT_FLOW_SPLIT, ConvertContext, ShapeResult, resolve_text_flow, ) from .paths import ( project_freeform_geometry_errors, project_gradient_geometry_errors, ) from .theme_colors import ThemeColorSpec from .theme_fonts import ThemeFontSpec from .text_properties import ( materialize_project_text_metrics, project_text_property_errors, resolve_project_font_sizes, resolve_project_letter_spacings, ) from .utils import ( EMU_PER_PX, SVG_NS, _extract_inheritable_styles, _get_attr, _is_unit_axis_reflection, is_picture_effect_carrier, parse_svg_length, parse_transform_operations, parse_transform_matrix, project_definition_errors, project_definition_index, project_filter_errors, project_geometry_length_errors, project_gradient_errors, project_image_aspect_ratio_errors, project_mask_errors, project_marker_errors, project_opacity_errors, project_paint_errors, project_paint_reference_errors, project_stroke_style_errors, project_transform_errors, resolve_url_id, supports_full_project_transform, validate_dml_shape_matrix, ) from .styles import ( build_effect_xml, build_fill_xml, get_element_opacity, get_fill_opacity, get_stroke_opacity, ) from .elements import ( convert_rect, convert_circle, convert_ellipse, convert_line, convert_path, convert_polygon, convert_polyline, convert_text, convert_image, convert_nested_svg, project_clip_path_errors, project_image_errors, project_nested_svg_crop_errors, ) from ..animation_config import is_chrome_id, usable_animation_group_id from ..canvas_contract import ( CanvasContractError, parse_project_svg_root, parse_project_viewbox, ) from ..native_objects import ( NativeMarkerAttributeError, convert_native_object, native_metadata_payload_matches, native_replacement_kind, native_marker_transform, snapshot_native_fallback_freshness, ) from ..native_objects.marker_status import native_marker_status_errors from ..semantic_markers import is_static_page_frame class SvgNativeConversionError(RuntimeError): """Raised when an SVG cannot be faithfully converted to native DrawingML.""" def _hydrate_native_payloads(root: ET.Element, svg_path: Path) -> int: """Resolve compressed workspace payload references for native conversion.""" try: return hydrate_native_payload_refs(root, svg_path) except NativePayloadError as exc: raise SvgNativeConversionError( f"{svg_path.name}: invalid native payload reference: {exc}" ) from exc def _require_chart_table_marker_attributes( root: ET.Element, svg_path: Path | str, ) -> None: """Reject contradictory chart/table marker aliases before either route.""" errors: list[str] = [] for elem in root.iter(): if elem.tag.rsplit('}', 1)[-1] == 'metadata': continue marker_errors = native_marker_status_errors(elem) if marker_errors: marker_id = elem.get('id') or elem.get('data-name') or '' errors.extend(f'{marker_id}: {error}' for error in marker_errors) continue marker_id = elem.get('id') or elem.get('data-name') or '' kind = native_replacement_kind(elem) if not kind: continue for child in elem: if child.tag.rsplit('}', 1)[-1] != 'metadata': continue try: native_metadata_payload_matches(child, kind) except NativeMarkerAttributeError as exc: errors.append(f'{marker_id}: {exc}') if not errors: return preview = '; '.join(errors[:8]) suffix = '' if len(errors) <= 8 else f'; +{len(errors) - 8} more' raise SvgNativeConversionError( f'{Path(svg_path).name}: invalid chart/table replacement metadata: ' f'{preview}{suffix}' ) def _require_project_freeform_geometry( root: ET.Element, svg_path: Path | str, ) -> None: """Reject malformed path and points values with one aggregated error.""" errors = project_freeform_geometry_errors(root) if not errors: return preview = '; '.join(errors[:8]) suffix = '' if len(errors) <= 8 else f'; +{len(errors) - 8} more' raise SvgNativeConversionError( f'{Path(svg_path).name}: invalid project freeform geometry: ' f'{preview}{suffix}' ) def _require_project_nested_svg_crops( root: ET.Element, svg_path: Path | str, ) -> None: """Reject nested SVG outside the imported picture-crop transport.""" errors = project_nested_svg_crop_errors(root) if not errors: return preview = '; '.join(errors[:8]) suffix = '' if len(errors) <= 8 else f'; +{len(errors) - 8} more' raise SvgNativeConversionError( f'{Path(svg_path).name}: invalid nested SVG crop wrapper(s): ' f'{preview}{suffix}' ) def _require_project_clip_paths( root: ET.Element, svg_path: Path | str, ) -> None: """Reject clip references that cannot produce native picture geometry.""" errors = project_clip_path_errors(root) if not errors: return preview = '; '.join(errors[:8]) suffix = '' if len(errors) <= 8 else f'; +{len(errors) - 8} more' raise SvgNativeConversionError( f'{Path(svg_path).name}: invalid project clip-path(s): ' f'{preview}{suffix}' ) def _require_project_images( root: ET.Element, svg_path: Path | str, ) -> None: """Reject invalid picture frames and unresolved or corrupt sources.""" path = Path(svg_path) errors = project_image_errors(root, path.parent) if not errors: return preview = '; '.join(errors[:8]) suffix = '' if len(errors) <= 8 else f'; +{len(errors) - 8} more' raise SvgNativeConversionError( f'{path.name}: invalid project image(s): {preview}{suffix}' ) def _require_project_transforms( root: ET.Element, svg_path: Path | str, ) -> None: """Reject invalid project transform syntax and mappings before conversion.""" errors = project_transform_errors(root) if not errors: return preview = '; '.join(errors[:8]) suffix = '' if len(errors) <= 8 else f'; +{len(errors) - 8} more' raise SvgNativeConversionError( f'{Path(svg_path).name}: invalid project transform(s): ' f'{preview}{suffix}' ) def _require_project_stroke_styles( root: ET.Element, svg_path: Path | str, ) -> None: """Reject invalid project line-style syntax and mappings before conversion.""" errors = project_stroke_style_errors(root) if not errors: return preview = '; '.join(errors[:8]) suffix = '' if len(errors) <= 8 else f'; +{len(errors) - 8} more' raise SvgNativeConversionError( f'{Path(svg_path).name}: invalid project line style(s): ' f'{preview}{suffix}' ) def _require_project_image_aspect_ratios( root: ET.Element, svg_path: Path | str, ) -> None: """Reject ambiguous image fit/crop values before native conversion.""" errors = project_image_aspect_ratio_errors(root) if not errors: return preview = '; '.join(errors[:8]) suffix = '' if len(errors) <= 8 else f'; +{len(errors) - 8} more' raise SvgNativeConversionError( f'{Path(svg_path).name}: invalid project image aspect ratio(s): ' f'{preview}{suffix}' ) def _require_project_opacities( root: ET.Element, svg_path: Path | str, ) -> None: """Reject malformed opacity values before native conversion.""" errors = project_opacity_errors(root) if not errors: return preview = '; '.join(errors[:8]) suffix = '' if len(errors) <= 8 else f'; +{len(errors) - 8} more' raise SvgNativeConversionError( f'{Path(svg_path).name}: invalid project opacity value(s): ' f'{preview}{suffix}' ) def _require_project_paints( root: ET.Element, svg_path: Path | str, ) -> None: """Reject invalid paint values before native conversion.""" errors = project_paint_errors(root) if not errors: return preview = '; '.join(errors[:8]) suffix = '' if len(errors) <= 8 else f'; +{len(errors) - 8} more' raise SvgNativeConversionError( f'{Path(svg_path).name}: invalid project paint value(s): ' f'{preview}{suffix}' ) def _require_project_masks( root: ET.Element, svg_path: Path | str, ) -> None: """Reject SVG masks before native conversion can silently drop them.""" errors = project_mask_errors(root) if not errors: return preview = '; '.join(errors[:8]) suffix = '' if len(errors) <= 8 else f'; +{len(errors) - 8} more' raise SvgNativeConversionError( f'{Path(svg_path).name}: invalid project mask(s): ' f'{preview}{suffix}' ) def _require_project_definitions( root: ET.Element, svg_path: Path | str, ) -> None: """Reject definitions outside the direct, unique local-ref contract.""" errors = project_definition_errors(root) if not errors: return preview = '; '.join(errors[:8]) suffix = '' if len(errors) <= 8 else f'; +{len(errors) - 8} more' raise SvgNativeConversionError( f'{Path(svg_path).name}: invalid project definition(s): ' f'{preview}{suffix}' ) def _require_project_paint_references( root: ET.Element, svg_path: Path | str, ) -> None: """Reject unresolved or context-invalid local paint references.""" errors = project_paint_reference_errors(root) if not errors: return preview = '; '.join(errors[:8]) suffix = '' if len(errors) <= 8 else f'; +{len(errors) - 8} more' raise SvgNativeConversionError( f'{Path(svg_path).name}: invalid project paint reference(s): ' f'{preview}{suffix}' ) def _require_project_line_end_markers( root: ET.Element, svg_path: Path | str, ) -> None: """Reject markers outside the native line-end contract.""" errors = project_marker_errors(root) if not errors: return preview = '; '.join(errors[:8]) suffix = '' if len(errors) <= 8 else f'; +{len(errors) - 8} more' raise SvgNativeConversionError( f'{Path(svg_path).name}: invalid project line-end marker(s): ' f'{preview}{suffix}' ) def _require_project_gradients( root: ET.Element, svg_path: Path | str, ) -> None: """Reject gradients outside the normalized native interface.""" errors = project_gradient_errors(root) + project_gradient_geometry_errors(root) if not errors: return preview = '; '.join(errors[:8]) suffix = '' if len(errors) <= 8 else f'; +{len(errors) - 8} more' raise SvgNativeConversionError( f'{Path(svg_path).name}: invalid project gradient(s): ' f'{preview}{suffix}' ) def _require_project_filters( root: ET.Element, svg_path: Path | str, ) -> None: """Reject filters outside the native shadow/glow interface.""" errors = project_filter_errors(root) if not errors: return preview = '; '.join(errors[:8]) suffix = '' if len(errors) <= 8 else f'; +{len(errors) - 8} more' raise SvgNativeConversionError( f'{Path(svg_path).name}: invalid project filter(s): ' f'{preview}{suffix}' ) def _require_project_effect_status( root: ET.Element, svg_path: Path | str, ) -> None: """Reject source effects that the importer cannot map without distortion.""" errors = project_effect_status_errors(root) if not errors: return preview = '; '.join(errors[:8]) suffix = '' if len(errors) <= 8 else f'; +{len(errors) - 8} more' raise SvgNativeConversionError( f'{Path(svg_path).name}: unsupported imported PPTX effect(s): ' f'{preview}{suffix}' ) def _require_project_text_properties( root: ET.Element, svg_path: Path | str, ) -> None: """Reject text declarations outside the closed DrawingML mapping.""" errors = project_text_property_errors(root) if not errors: return preview = '; '.join(errors[:8]) suffix = '' if len(errors) <= 8 else f'; +{len(errors) - 8} more' raise SvgNativeConversionError( f'{Path(svg_path).name}: invalid project text property(s): ' f'{preview}{suffix}' ) # --------------------------------------------------------------------------- # Transform & layout helpers # --------------------------------------------------------------------------- def parse_transform(transform_str: str) -> tuple[float, float, float, float, float]: """Parse an SVG transform list into (dx, dy, sx, sy, angle_deg). Composes every translate/scale/rotate/matrix operation rather than picking the first occurrence — needed for idioms like ``translate(cx cy) scale(-1 -1) translate(-cx -cy)`` which encode a flip around a non-origin pivot. When the composed matrix has no rotation, the decomposition preserves signed scale for flips. With rotation, scale uses the column magnitudes and angle uses the first transformed axis. Zero or non-orthogonal axes fail before decomposition because this tuple cannot represent them faithfully. """ if not transform_str: return 0.0, 0.0, 1.0, 1.0, 0.0 matrix = parse_transform_matrix(transform_str) validate_dml_shape_matrix(matrix) a, b, c, d, e, f = matrix # No shear / rotation: direct decomposition preserves the original signs of # sx / sy. ctx_x / ctx_y use the simple ``val * sx + tx`` formula, so this # is the only form that survives flip-around-pivot composites without # collapsing them into a rotation that the consumer can't honour. if abs(b) < 1e-9 and abs(c) < 1e-9: sx = a if a != 0 else 1.0 sy = d if d != 0 else 1.0 return e, f, sx, sy, 0.0 sx = math.hypot(a, b) sy = math.hypot(c, d) if sx == 0: sx = 1.0 if sy == 0: sy = 1.0 angle_deg = math.degrees(math.atan2(b, a)) return e, f, sx, sy, angle_deg # ``rotate(angle)`` defaults to pivot (0,0); ``rotate(angle, cx, cy)`` rotates # around (cx, cy). DrawingML grpSp ``rot`` always rotates around the group's # own bounding-box centre — we need the SVG pivot so ``convert_g`` can # compensate for the offset between those two centres. def _root_viewport_size(root: ET.Element) -> tuple[float, float]: """Return the SVG root viewport size in user units.""" viewbox = parse_project_viewbox(root.get('viewBox')) return float(viewbox.width), float(viewbox.height) def _extract_rotate_pivot(transform_str: str) -> tuple[float, float] | None: """Return the (cx, cy) pivot of a sole ``rotate(...)`` in *transform_str*. Returns ``None`` when the transform list contains anything other than one rotate (other ops compose with rotate in a way the pivot-compensation fallback can't express). A bare ``rotate(angle)`` returns (0, 0). """ if not transform_str: return None operations = parse_transform_operations(transform_str) if len(operations) != 1 or operations[0][0] != 'rotate': return None args = operations[0][1] cx = args[1] if len(args) == 3 else 0.0 cy = args[2] if len(args) == 3 else 0.0 return cx, cy def _txbody_metadata(elem: ET.Element) -> ET.Element | None: for child in elem: if ( child.tag.replace(f'{{{SVG_NS}}}', '') == 'metadata' and child.get('data-pptx-part') == 'txbody' ): return child return None _TXBODY_UNCHANGED_ATTR = 'data-pptx-runtime-txbody-unchanged' _PREVIEW_UNCHANGED_ATTR = 'data-pptx-runtime-preview-unchanged' def _mark_unchanged_txbody_groups(root: ET.Element) -> None: """Snapshot author-visible text state before exporter preprocessing.""" for group in root.iter(): if group.tag.replace(f'{{{SVG_NS}}}', '') != 'g': continue metadata = _txbody_metadata(group) if metadata is None: continue expected = metadata.get('data-pptx-text-sha256') actual = svg_text_fingerprint(group) group.set(_TXBODY_UNCHANGED_ATTR, '1' if expected == actual else '0') def _mark_unchanged_preset_previews(root: ET.Element) -> None: """Snapshot visible preset layers before exporter preprocessing.""" for group in root.iter(): if group.tag.replace(f'{{{SVG_NS}}}', '') != 'g': continue if ( group.get('data-pptx-object') not in {'shape', 'connector'} or group.get('data-pptx-prst') is None ): continue try: expected = resolve_preset_preview_hash(group) except ValueError as exc: raise SvgNativeConversionError( f'Invalid preset preview fingerprint contract: {exc}' ) from exc if expected is None: continue actual = svg_preset_preview_fingerprint(group) group.set(_PREVIEW_UNCHANGED_ATTR, '1' if expected == actual else '0') def _require_unchanged_preset_preview(group: ET.Element) -> None: try: expected = resolve_preset_preview_hash(group) except ValueError as exc: raise SvgNativeConversionError( f'Invalid preset preview fingerprint contract: {exc}' ) from exc if expected is None: return snapshot = group.get(_PREVIEW_UNCHANGED_ATTR) if snapshot == '1': return if snapshot is None and svg_preset_preview_fingerprint(group) == expected: return raise SvgNativeConversionError( 'Visible preset preview was edited without updating its native ' 'data-pptx-prst/frame/adjustment carrier; export stopped to avoid ' 'silently discarding the SVG edit' ) def _decode_unchanged_txbody( group: ET.Element, metadata: ET.Element, *, trust_runtime_snapshot: bool = True, ) -> tuple[str, bool] | None: expected_hash = metadata.get('data-pptx-text-sha256') if not expected_hash: raise SvgNativeConversionError('txbody metadata requires a text hash') snapshot = ( group.get(_TXBODY_UNCHANGED_ATTR) if trust_runtime_snapshot else None ) unchanged = snapshot == '1' or ( snapshot != '0' and svg_text_fingerprint(group) == expected_hash ) if metadata.get('data-pptx-encoding') != 'base64': raise SvgNativeConversionError('txbody metadata requires base64 encoding') try: raw = base64.b64decode((metadata.text or '').strip(), validate=True) txbody = ET.fromstring(raw) decoded = raw.decode('utf-8') except (ValueError, binascii.Error, UnicodeDecodeError, ET.ParseError) as exc: raise SvgNativeConversionError(f'Invalid txbody metadata: {exc}') from exc if txbody.tag != ( '{http://schemas.openxmlformats.org/presentationml/2006/main}txBody' ): raise SvgNativeConversionError('txbody metadata payload must be p:txBody') if has_relationship_attributes(txbody): raise SvgNativeConversionError( 'txbody metadata must not contain part-local relationship attributes' ) if not unchanged: if txbody_has_run_effects(txbody): raise SvgNativeConversionError( 'Visible text or typography was edited while the source ' 'txBody contains run-level effects; export stopped to avoid ' 'silently discarding those effects' ) return None return decoded, txbody_has_run_effects(txbody) def _append_shape_text( shape: ShapeResult, txbody_xml: str, ) -> ShapeResult: if not shape.xml.lstrip().startswith('') or not shape.xml.rstrip().endswith(''): raise SvgNativeConversionError('Native txBody can only attach to p:sp') closing = shape.xml.rfind('') return ShapeResult( xml=( shape.xml[:closing] + txbody_xml + '\n' + shape.xml[closing:] ), bounds_emu=shape.bounds_emu, ) def preserved_native_text_body( group: ET.Element, *, trust_runtime_snapshot: bool = True, ) -> tuple[ET.Element, str] | None: """Return the geometry carrier and unchanged native text body, if usable.""" metadata = _txbody_metadata(group) logical_text_shape = ( group.get('data-pptx-object') == 'shape' and ( group.get('data-pptx-prst') is not None or group.get('data-pptx-geometry-kind') == 'custom' ) and metadata is not None ) if not logical_text_shape: return None decoded_text = _decode_unchanged_txbody( group, metadata, trust_runtime_snapshot=trust_runtime_snapshot, ) carrier_children = [ child for child in group if child.get('data-pptx-part') == 'geometry' ] allowed_parts = { 'geometry', 'geometry-detail', 'geometry-preview', 'txbody', } has_foreign_visual = any( child.tag.replace(f'{{{SVG_NS}}}', '') not in {'text', 'metadata'} and child.get('data-pptx-part') not in allowed_parts for child in group ) if decoded_text is None: return None native_text, has_run_effects = decoded_text if len(carrier_children) != 1 or has_foreign_visual: if has_run_effects: raise SvgNativeConversionError( 'The source txBody contains run-level effects but cannot be ' 'restored as one native text shape; export stopped to avoid ' 'silently discarding those effects' ) return None return carrier_children[0], native_text # --------------------------------------------------------------------------- # Group handling # --------------------------------------------------------------------------- def convert_g(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None: """Convert SVG to DrawingML group shape . Preserves group structure so elements can be selected and moved together in PowerPoint. Single-child groups are flattened to avoid unnecessary nesting. Uses identity coordinate mapping (chOff/chExt == off/ext) so child shapes keep their absolute slide coordinates unchanged. """ transform = elem.get('transform', '') native_subtree_active = ctx.native_objects_enabled and any( native_replacement_kind(descendant) and descendant.tag.replace(f'{{{SVG_NS}}}', '') != 'metadata' for descendant in elem.iter() ) if native_subtree_active: dx, dy, sx, sy = native_marker_transform(transform) angle_deg = 0.0 else: dx, dy, sx, sy, angle_deg = parse_transform(transform) filter_id = resolve_url_id(elem.get('filter', '')) style_overrides = _extract_inheritable_styles(elem) local_opacity = get_element_opacity(elem) if local_opacity is None: local_opacity = 1.0 elem_id = usable_animation_group_id(elem.get('id')) semantic_role = elem.get('data-pptx-role') placeholder = elem.get('data-pptx-placeholder') has_explicit_semantics = ( semantic_role is not None or placeholder is not None ) is_chrome = ( is_static_page_frame(semantic_role, placeholder) if has_explicit_semantics else is_chrome_id(elem_id) ) should_animate_group = ( ctx.depth == 0 and elem_id and ( not is_chrome or ( not has_explicit_semantics and elem_id in ctx.animation_group_overrides ) ) and elem.get('data-pptx-layer') is None ) visual_children = [ child for child in elem if child.tag.replace(f'{{{SVG_NS}}}', '') not in _NON_VISUAL_TAGS ] unit_axis_reflection = ( bool(transform) and _is_unit_axis_reflection(parse_transform_operations(transform)) ) matrix_supported = ( not native_subtree_active and bool(transform) and visual_children and ( supports_full_project_transform(elem) or unit_axis_reflection ) ) # A pure ``rotate(angle [cx cy])`` falls through to the fallback path # below (children are rect/text/path/etc. that don't consume a full # matrix). Decomposing the matrix produces translation components # (e, f) that encode the pivot — handing those to children would # *double-translate* them because grpSp's own ``rot`` already # rotates around the group's bounding-box centre. Skip the child # translation here and apply pivot-centre compensation to ``a:off`` # below instead. rotate_pivot = _extract_rotate_pivot(transform) if not matrix_supported else None if rotate_pivot is not None: angle_deg = parse_transform_operations(transform)[0][1][0] if matrix_supported: child_ctx = ctx.child( 0, 0, 1.0, 1.0, transform_matrix=parse_transform_matrix(transform), filter_id=filter_id, style_overrides=style_overrides, opacity_multiplier=local_opacity, ) elif rotate_pivot is not None: child_ctx = ctx.child( 0, 0, 1.0, 1.0, filter_id=filter_id, style_overrides=style_overrides, opacity_multiplier=local_opacity, ) else: child_ctx = ctx.child( ctx.scale_x * dx if native_subtree_active else dx, ctx.scale_y * dy if native_subtree_active else dy, sx, sy, filter_id=filter_id, style_overrides=style_overrides, opacity_multiplier=local_opacity, ) if native_subtree_active and child_ctx.opacity_multiplier < 1.0: raise SvgNativeConversionError( "Group opacity cannot be applied to data-pptx-replace-with chart/table " "objects; export without --native-charts-and-tables to use the " "shape-based SVG fallback" ) if child_ctx.native_objects_enabled: native_result = convert_native_object(elem, child_ctx) if native_result: ctx.sync_from_child(child_ctx) if should_animate_group: shape_match = re.search(r' so PowerPoint can animate the group as one unit. min_x = min_y = float('inf') max_x = max_y = float('-inf') for child_result in child_results: bounds = child_result.bounds_emu if bounds is None: continue min_x = min(min_x, bounds[0]) min_y = min(min_y, bounds[1]) max_x = max(max_x, bounds[2]) max_y = max(max_y, bounds[3]) if min_x == float('inf'): return ShapeResult(xml='\n'.join(result.xml for result in child_results)) group_x = int(min_x) group_y = int(min_y) group_w = max(int(max_x - min_x), 1) group_h = max(int(max_y - min_y), 1) # ``rotate(angle, cx, cy)`` rotates around the SVG pivot, but DrawingML # grpSp ``rot`` always rotates around the group's own bbox centre. When # those centres differ, the visual position drifts by exactly the # translation a rotate-around-pivot equals. Compensate by offsetting the # outer only; stays on the unshifted bbox so children # (still at their original SVG positions because rotate_pivot suppressed # the dx/dy translation above) remain aligned inside the group. off_x = group_x off_y = group_y if rotate_pivot is not None and angle_deg: cx_svg, cy_svg = rotate_pivot pivot_ex = (cx_svg + ctx.translate_x) * EMU_PER_PX pivot_ey = (cy_svg + ctx.translate_y) * EMU_PER_PX bbox_cx = group_x + group_w / 2 bbox_cy = group_y + group_h / 2 theta = math.radians(angle_deg) cos_t = math.cos(theta) sin_t = math.sin(theta) # Where the bbox centre lands after rotating around the pivot, minus # where DrawingML's grpSp rot would leave it (i.e. unchanged). delta_x = (bbox_cx - pivot_ex) * cos_t - (bbox_cy - pivot_ey) * sin_t + pivot_ex - bbox_cx delta_y = (bbox_cx - pivot_ex) * sin_t + (bbox_cy - pivot_ey) * cos_t + pivot_ey - bbox_cy off_x = int(round(group_x + delta_x)) off_y = int(round(group_y + delta_y)) shapes_xml = '\n'.join(result.xml for result in child_results) group_id = ( ctx.claim_shape_id( elem.get('data-pptx-shape-id'), elem.get('data-pptx-shape-scope'), ) if elem.get('data-pptx-object') == 'group' else ctx.next_id() ) # Record top-level semantic groups (e.g. ) so the # PPTX builder can emit per-element object timing. Only the outermost # multi-child wrapper qualifies — flattened single-child groups have no # to anchor a timing target on, and nested groups are # ignored to keep the animation budget at ~per-section granularity. if should_animate_group: ctx.anim_targets.append((group_id, elem_id)) group_effect = '' if filter_id and filter_id in ctx.defs: group_effect = build_effect_xml( ctx.defs[filter_id], child_ctx.opacity_multiplier, ) rot_emu = 0 if matrix_supported else int(angle_deg * 60000) rot_attr = f' rot="{rot_emu}"' if rot_emu else '' validate_ooxml_xfrm(off_x, off_y, group_w, group_h) validate_ooxml_xfrm(group_x, group_y, group_w, group_h) return ShapeResult(xml=f''' {group_effect} {shapes_xml} ''', bounds_emu=(group_x, group_y, group_x + group_w, group_y + group_h)) # --------------------------------------------------------------------------- # Defs collection & element dispatch # --------------------------------------------------------------------------- _NON_VISUAL_TAGS = frozenset(('defs', 'title', 'desc', 'metadata', 'style')) _CONVERTERS = { 'rect': convert_rect, 'circle': convert_circle, 'ellipse': convert_ellipse, 'line': convert_line, 'path': convert_path, 'polygon': convert_polygon, 'polyline': convert_polyline, 'text': convert_text, 'image': convert_image, 'g': convert_g, 'svg': convert_nested_svg, } _SUPPORTED_VISUAL_CHILD_TAGS = frozenset(('tspan',)) def _parse_svg_canvas(root: ET.Element) -> tuple[float, float, float, float]: """Return the SVG canvas as (x, y, width, height) in SVG units.""" viewbox = parse_project_viewbox(root.get('viewBox')) return 0.0, 0.0, float(viewbox.width), float(viewbox.height) def _is_full_canvas_rect( elem: ET.Element, ctx: ConvertContext, canvas: tuple[float, float, float, float], ) -> bool: """Return whether a rect is a safe candidate for native slide background.""" if elem.get('transform') or elem.get('filter') or elem.get('clip-path'): return False if any( elem.get(attr) is not None for attr in ( 'data-pptx-object', 'data-pptx-prst', 'data-pptx-frame', 'data-pptx-geometry-status', ) ): return False if ( parse_svg_length(elem.get('rx'), 0.0) > 0 or parse_svg_length(elem.get('ry'), 0.0) > 0 ): return False canvas_x, canvas_y, canvas_w, canvas_h = canvas if canvas_w <= 0 or canvas_h <= 0: return False tolerance = 0.5 if abs(parse_svg_length(elem.get('x'), 0.0) - canvas_x) > tolerance: return False if abs(parse_svg_length(elem.get('y'), 0.0) - canvas_y) > tolerance: return False if abs(parse_svg_length(elem.get('width'), 0.0) - canvas_w) > tolerance: return False if abs(parse_svg_length(elem.get('height'), 0.0) - canvas_h) > tolerance: return False fill = _get_attr(elem, 'fill', ctx) if fill == 'none': return False stroke = _get_attr(elem, 'stroke', ctx) stroke_width = parse_svg_length(_get_attr(elem, 'stroke-width', ctx), 1.0) stroke_opacity = get_stroke_opacity(elem, ctx) if stroke and stroke != 'none' and stroke_width > 0 and stroke_opacity != 0: return False return True def _background_xml_from_rect( elem: ET.Element, ctx: ConvertContext, ) -> str: """Build native ``p:bg`` XML from a full-slide SVG background rect.""" fill_xml = build_fill_xml( elem, ctx, get_fill_opacity(elem, ctx), usage="background", ) if not fill_xml or '{fill_xml}' def _extract_background_candidate( root: ET.Element, ctx: ConvertContext, ) -> tuple[str, int | None]: """Promote a first-layer SVG background rect to native PowerPoint bgPr. PowerPoint stores page background fills under ``p:cSld/p:bg/p:bgPr``. Keeping the full-canvas SVG rect in ``p:spTree`` makes it an ordinary selectable shape, so users hit it during bulk element selection. Only the first visual layer is considered, matching pptx_to_svg's round-trip output and avoiding accidental promotion of content panels. """ canvas = _parse_svg_canvas(root) for child in root: tag = child.tag.replace(f'{{{SVG_NS}}}', '') if tag in _NON_VISUAL_TAGS: continue if tag == 'rect' and _is_full_canvas_rect(child, ctx, canvas): bg_xml = _background_xml_from_rect(child, ctx) if bg_xml: return bg_xml, id(child) return '', None if tag != 'g': return '', None if child.get('transform') or child.get('filter') or child.get('clip-path'): return '', None style_overrides = _extract_inheritable_styles(child) local_opacity = get_element_opacity(child) child_ctx = ctx.child( style_overrides=style_overrides, opacity_multiplier=1.0 if local_opacity is None else local_opacity, ) visual_children = [ grandchild for grandchild in child if grandchild.tag.replace(f'{{{SVG_NS}}}', '') not in _NON_VISUAL_TAGS ] if len(visual_children) != 1: return '', None only_child = visual_children[0] only_tag = only_child.tag.replace(f'{{{SVG_NS}}}', '') if only_tag == 'rect' and _is_full_canvas_rect(only_child, child_ctx, canvas): bg_xml = _background_xml_from_rect(only_child, child_ctx) if bg_xml: ctx.sync_from_child(child_ctx) return bg_xml, id(child) return '', None return '', None return '', None def collect_defs(root: ET.Element) -> dict[str, ET.Element]: """Collect all children into an {id: element} dictionary.""" definitions, _duplicates = project_definition_index(root) return definitions def _build_source_shape_id_map(root: ET.Element) -> dict[tuple[str, str], int]: """Allocate page-unique ids for part-scoped imported shape identities.""" source_entries: list[tuple[tuple[str, str], int]] = [] seen_keys: set[tuple[str, str]] = set() for elem in root.iter(): raw_id = elem.get('data-pptx-shape-id') if raw_id is None: continue scope = elem.get('data-pptx-shape-scope') or 'slide' if re.fullmatch(r'[A-Za-z0-9_.-]{1,64}', scope) is None: raise SvgNativeConversionError( f'Invalid data-pptx-shape-scope {scope!r}' ) try: shape_id = int(raw_id) except ValueError as exc: raise SvgNativeConversionError( f'Invalid data-pptx-shape-id {raw_id!r}' ) from exc if shape_id < 2 or shape_id > 0xFFFFFFFF: raise SvgNativeConversionError( f'data-pptx-shape-id must be between 2 and 4294967295, got {raw_id!r}' ) key = (scope, raw_id) if key in seen_keys: continue seen_keys.add(key) source_entries.append((key, shape_id)) preferred_ids = {shape_id for _key, shape_id in source_entries} next_fresh = max(preferred_ids, default=1) + 1 used: set[int] = set() mapping: dict[tuple[str, str], int] = {} for key, preferred in source_entries: output_id = preferred if output_id in used: while next_fresh in preferred_ids or next_fresh in used: next_fresh += 1 if next_fresh > 0xFFFFFFFF: raise SvgNativeConversionError('Exhausted PowerPoint shape id range') output_id = next_fresh next_fresh += 1 used.add(output_id) mapping[key] = output_id return mapping def _geometry_trace_metadata(elem: ET.Element, result: ShapeResult) -> dict[str, Any]: """Describe the native geometry decision for conversion diagnostics.""" xml = result.xml.lstrip() if xml.startswith(''): return {'output_geometry': 'group', 'fidelity': 'visual-only'} if xml.startswith(''): return {'output_geometry': 'picture', 'fidelity': 'native-normalized'} if xml.startswith(''): return {'output_geometry': 'native-object', 'fidelity': 'native-normalized'} preset_match = re.search(r'') fidelity = ( 'exact' if source_preset == preset and elem.get('data-pptx-frame') is not None and not is_connector else 'native-normalized' ) return { 'output_geometry': 'preset', 'preset': preset, 'fidelity': fidelity, } if re.search(r')', xml): carrier = next( ( candidate for candidate in elem.iter() if candidate.get('data-pptx-part') == 'geometry' ), elem, ) source_custom = ( carrier.get('data-pptx-geometry-kind') == 'custom' and carrier.get('data-pptx-frame') is not None ) expected_hash = carrier.get('data-pptx-geometry-sha256') actual_hash = hashlib.sha256( (carrier.get('d') or '').strip().encode('utf-8') ).hexdigest() unchanged = source_custom and expected_hash == actual_hash if unchanged: fidelity = 'exact' geometry_source = 'preserved-metadata' elif source_custom: fidelity = 'native-normalized' geometry_source = 'svg-recompiled' else: fidelity = 'visual-only' geometry_source = 'svg-authored' return { 'output_geometry': 'custom', 'fidelity': fidelity, 'geometry_source': geometry_source, } return {'output_geometry': 'unknown', 'fidelity': 'visual-only'} def convert_element(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None: """Dispatch an SVG element to the appropriate converter.""" tag = elem.tag.replace(f'{{{SVG_NS}}}', '') elem_id = elem.get('id') def trace(decision: str, **metadata: Any) -> None: if ctx.trace_events is None: return event: dict[str, Any] = { 'tag': tag, 'decision': decision, } if elem_id: event['id'] = elem_id for attr in ( 'data-pptx-layer', 'data-pptx-object', 'data-pptx-shape-id', 'data-pptx-frame', 'data-pptx-prst', 'data-pptx-part', 'data-pptx-geometry-status', 'data-pptx-geometry-reason', 'data-pptx-placeholder', 'data-pptx-bounds', 'data-pptx-carrier', 'data-pptx-idx', 'data-pptx-role', ): value = elem.get(attr) if value is not None: event[attr] = value adjustments = { attr[len('data-pptx-av-'):]: value for attr, value in elem.attrib.items() if attr.startswith('data-pptx-av-') } if adjustments: event['adjustments'] = dict(sorted(adjustments.items())) event.update(metadata) ctx.trace_events.append(event) if elem.get('data-pptx-part') == 'geometry-detail': trace('skip', reason='render-only-preset-geometry-detail') return None converter = _CONVERTERS.get(tag) if converter: try: result = converter(elem, ctx) except Exception as e: trace('error', error=str(e)) raise SvgNativeConversionError(f'Failed to convert <{tag}>: {e}') from e if result: shape_match = re.search(r'') def _local_tag(elem: ET.Element) -> str: if not isinstance(elem.tag, str): return str(elem.tag) prefix = f'{{{SVG_NS}}}' return elem.tag[len(prefix):] if elem.tag.startswith(prefix) else elem.tag def collect_unsupported_visuals( root: ET.Element, *, allow_data_icon_use: bool = False, ) -> list[str]: """Return visual element paths that the native converter cannot dispatch.""" issues: list[str] = [] def walk( elem: ET.Element, path: str, in_defs: bool = False, parent_tag: str | None = None, ) -> None: tag = _local_tag(elem) current = f'{path}/{tag}' if in_defs: return if tag in _NON_VISUAL_TAGS: return is_supported_visual_child = ( tag in _SUPPORTED_VISUAL_CHILD_TAGS and parent_tag in {'text', 'tspan'} ) is_data_icon_placeholder = ( allow_data_icon_use and tag == 'use' and elem.get('data-icon') is not None ) if (tag not in _CONVERTERS and tag not in _NON_VISUAL_TAGS and not is_supported_visual_child and not is_data_icon_placeholder): issues.append(current) for idx, child in enumerate(list(elem), start=1): walk( child, f'{current}[{idx}]', in_defs=(tag == 'defs'), parent_tag=tag, ) for idx, child in enumerate(list(root), start=1): walk(child, f'/svg[{idx}]', parent_tag='svg') return issues def convert_svg_to_slide_shapes( svg_path: str | Path, slide_num: int = 1, verbose: bool = False, merge_paragraphs: bool | None = None, image_optimize: bool = True, image_max_dimension: int | None = 2560, image_sizing: str = 'cap', image_scale: float = 2.0, image_quality: int = 85, native_objects: bool = False, animation_group_overrides: frozenset[str] | None = None, theme_font_spec: ThemeFontSpec | None = None, theme_color_spec: ThemeColorSpec | None = None, primary_language: str | None = None, trace_out: list[dict[str, Any]] | None = None, promote_background: bool = True, text_flow: str | None = None, ) -> tuple[ str, dict[str, bytes], list[dict[str, str]], list, dict[str, bytes], dict[str, str], ]: """Convert an SVG file to a complete DrawingML slide XML. Args: svg_path: Path to the SVG file. slide_num: Slide number (for naming). verbose: Print progress info. merge_paragraphs: Legacy compatibility option. True selects reflow; False selects split. Do not combine with ``text_flow``. text_flow: Positional-tspan policy. ``preserve`` keeps authored visual line breaks in one frame, ``reflow`` lets PowerPoint wrap the text, and ``split`` emits one text frame per visual line. image_optimize: Downsample oversized raster images for PPTX export. image_max_dimension: Maximum optimized image dimension in pixels. image_sizing: ``cap`` to only cap source dimensions, ``display`` to size from rendered SVG boxes. image_scale: Target image pixels per SVG display pixel. image_quality: JPEG quality used for opaque optimized rasters. native_objects: Convert explicit ``data-pptx-replace-with`` chart/table markers to native PowerPoint Chart/Table objects. Default off. animation_group_overrides: Explicit top-level SVG group ids from ``animations.json`` that override the legacy chrome-name fallback. Explicit structural layer/role/placeholder markers remain excluded. theme_font_spec: Optional major/minor theme-font contract. Matching SVG families emit DrawingML theme tokens instead of fixed typefaces. theme_color_spec: Optional context-aware theme-color contract. Exact locked colors emit DrawingML scheme tokens while local colors stay fixed. primary_language: Canonical BCP-47 project content language. ``None`` keeps the legacy per-run script heuristic. trace_out: Optional list populated with one per-slide trace dictionary. promote_background: Promote the first eligible full-canvas rectangle into native ``p:bg``. Structured export disables this generic pass and applies its narrower explicit background contract later. Returns: (slide_xml, media_files, rel_entries, anim_targets, package_files, content_type_overrides) where: - slide_xml: Complete slide XML string. - media_files: Dict of {filename: bytes} for media to write. - rel_entries: List of relationship entries to add. - anim_targets: List of (shape_id, svg_id) tuples for top-level semantic groups, in z-order; consumed by the builder's optional per-element object-animation timing emitter. - package_files: Dict of {pptx internal path: bytes} for non-media OOXML parts such as native chart XML and embedded workbooks. - content_type_overrides: Dict of {pptx internal path: content type} for package_files that require [Content_Types].xml overrides. """ text_flow = resolve_text_flow(text_flow, merge_paragraphs) svg_path = Path(svg_path) tree = ET.parse(str(svg_path)) root = tree.getroot() _hydrate_native_payloads(root, svg_path) try: parse_project_svg_root( root, context=svg_path.name, ) except CanvasContractError as exc: raise SvgNativeConversionError(str(exc)) from exc _require_chart_table_marker_attributes(root, svg_path) _require_project_nested_svg_crops(root, svg_path) _require_project_clip_paths(root, svg_path) authored_errors = validate_authored_preset_tree(root) if authored_errors: raise SvgNativeConversionError( 'Invalid authored preset structure: ' + '; '.join(authored_errors) ) # Validate the source contract once, then lower compact groups to the # established expanded transport IR. Downstream conversion validates the # generated preview hash, not the source-format allowlist again. try: materialize_compact_authored_preset_tree(root) except ValueError as exc: raise SvgNativeConversionError( f'Invalid compact authored preset: {exc}' ) from exc _mark_unchanged_txbody_groups(root) _mark_unchanged_preset_previews(root) if native_objects: try: snapshot_native_fallback_freshness(root) except NativeMarkerAttributeError as exc: raise SvgNativeConversionError( f'{Path(svg_path).name}: conflicting chart/table replacement ' f'metadata: {exc}' ) from exc trace_events: list[dict[str, Any]] | None = [] if trace_out is not None else None trace_steps: list[dict[str, Any]] = [] from ..geometry_properties import ( GeometryStyleError, materialize_inline_geometry_properties, ) try: geometry_count = materialize_inline_geometry_properties(root) except GeometryStyleError as exc: raise SvgNativeConversionError( f'{svg_path.name}: inline geometry materialization failed: {exc}' ) from exc geometry_trace = None if geometry_count: geometry_trace = { 'action': 'materialize-inline-geometry', 'count': geometry_count, } trace_steps.append(geometry_trace) if verbose: print(f' Materialized {geometry_count} inline geometry declaration(s)') geometry_length_errors = project_geometry_length_errors(root) if geometry_length_errors: preview = '; '.join(geometry_length_errors[:8]) suffix = ( '' if len(geometry_length_errors) <= 8 else f'; +{len(geometry_length_errors) - 8} more' ) raise SvgNativeConversionError( f'{Path(svg_path).name}: invalid project geometry length(s): ' f'{preview}{suffix}' ) _require_project_text_properties(root, svg_path) _require_project_freeform_geometry(root, svg_path) _require_project_stroke_styles(root, svg_path) _require_project_opacities(root, svg_path) _require_project_paints(root, svg_path) _require_project_masks(root, svg_path) _require_project_definitions(root, svg_path) _require_project_paint_references(root, svg_path) _require_project_line_end_markers(root, svg_path) _require_project_gradients(root, svg_path) _require_project_effect_status(root, svg_path) _require_project_filters(root, svg_path) _require_project_image_aspect_ratios(root, svg_path) _require_project_transforms(root, svg_path) viewport_width, viewport_height = _root_viewport_size(root) # Expand project icon placeholders and static same-document # references before unsupported-element preflight. from ..use_expander import ( UseExpansionError, expand_local_use_references, expand_use_data_icons, ) icons_dir, icons_fallback_dir = icon_search_dirs_for_svg(svg_path) if icons_dir.exists(): expanded = expand_use_data_icons(root, icons_dir, icons_fallback_dir) if expanded: trace_steps.append({'action': 'expand-use-data-icons', 'count': expanded}) if verbose and expanded: print(f' Expanded {expanded} placeholder(s)') if expanded: hydrated = _hydrate_native_payloads(root, svg_path) if hydrated: trace_steps.append({ 'action': 'hydrate-native-payloads-from-icons', 'count': hydrated, }) _mark_unchanged_txbody_groups(root) _mark_unchanged_preset_previews(root) _require_project_freeform_geometry(root, svg_path) try: injected_geometry_count = materialize_inline_geometry_properties(root) except GeometryStyleError as exc: raise SvgNativeConversionError( f'{svg_path.name}: expanded icon geometry materialization failed: {exc}' ) from exc if injected_geometry_count: geometry_count += injected_geometry_count if geometry_trace is None: geometry_trace = { 'action': 'materialize-inline-geometry', 'count': geometry_count, } trace_steps.append(geometry_trace) else: geometry_trace['count'] = geometry_count if verbose: print( f' Materialized {injected_geometry_count} inline geometry ' 'declaration(s) from expanded icons' ) try: expanded_local = expand_local_use_references(root) except UseExpansionError as exc: raise SvgNativeConversionError( f'{svg_path.name}: local expansion failed: {exc}' ) from exc if expanded_local: trace_steps.append({ 'action': 'expand-local-use-references', 'count': expanded_local, }) if verbose: print(f' Expanded {expanded_local} local instance(s)') # Recheck compiler-injected icon/use wrappers and cloned definition trees. _require_project_nested_svg_crops(root, svg_path) _require_project_images(root, svg_path) _require_project_clip_paths(root, svg_path) _require_project_text_properties(root, svg_path) _require_project_stroke_styles(root, svg_path) _require_project_opacities(root, svg_path) _require_project_paints(root, svg_path) _require_project_masks(root, svg_path) _require_project_definitions(root, svg_path) _require_project_paint_references(root, svg_path) _require_project_gradients(root, svg_path) _require_project_effect_status(root, svg_path) _require_project_filters(root, svg_path) _require_project_image_aspect_ratios(root, svg_path) _require_project_transforms(root, svg_path) try: materialize_project_text_metrics(root) except ValueError as exc: raise SvgNativeConversionError( f'{svg_path.name}: text-metric materialization failed: {exc}' ) from exc # Flatten positional (those with x/y/non-zero dy) into independent # elements. DrawingML runs cannot reposition mid-paragraph, so a # dy-stacked block of tspans would otherwise collapse onto one baseline, # and an x-anchored tspan would render in the wrong column. finalize_svg # does the same flattening on disk; doing it here keeps native pptx output # correct when reading raw svg_output/. # Preserve/reflow modes additionally fold conservative paragraph blocks # into one annotated . Preserve keeps visual lines as DrawingML hard # breaks; reflow joins wrapping lines; split keeps one frame per line. from ..tspan_flattener import flatten_positional_tspans flattened = flatten_positional_tspans( tree, merge_paragraphs=text_flow != TEXT_FLOW_SPLIT, preserve_line_breaks=text_flow == TEXT_FLOW_PRESERVE, ) if flattened: trace_steps.append({ 'action': 'flatten-positional-tspans', 'text_flow': text_flow, # Compatibility field for older trace readers. 'merge_paragraphs': text_flow != TEXT_FLOW_SPLIT, }) if verbose: print(f' Lowered positional using {text_flow} text flow') _require_project_text_properties(root, svg_path) try: text_font_sizes = resolve_project_font_sizes(root) text_letter_spacings = resolve_project_letter_spacings( root, text_font_sizes, ) except ValueError as exc: raise SvgNativeConversionError( f'{svg_path.name}: invalid project text-metric inheritance: {exc}' ) from exc unsupported = collect_unsupported_visuals(root) if unsupported: preview = '; '.join(unsupported[:8]) suffix = '' if len(unsupported) <= 8 else f'; +{len(unsupported) - 8} more' raise SvgNativeConversionError( f'{svg_path.name}: unsupported visual SVG element(s): {preview}{suffix}' ) defs = collect_defs(root) source_shape_id_map = _build_source_shape_id_map(root) ctx = ConvertContext( defs=defs, reserved_shape_ids=frozenset(source_shape_id_map.values()), source_shape_id_map=source_shape_id_map, slide_num=slide_num, viewport_width=viewport_width, viewport_height=viewport_height, svg_dir=Path(svg_path).parent, text_flow=text_flow, image_optimize=image_optimize, image_max_dimension=image_max_dimension, image_sizing=image_sizing, image_scale=image_scale, image_quality=image_quality, native_objects_enabled=native_objects, animation_group_overrides=animation_group_overrides or frozenset(), trace_events=trace_events, theme_font_spec=theme_font_spec, theme_color_spec=theme_color_spec, primary_language=primary_language, inherited_styles=_extract_inheritable_styles(root), text_font_sizes=text_font_sizes, text_letter_spacings=text_letter_spacings, ) shapes: list[str] = [] converted = 0 skipped = 0 has_top_level_group = any( child.tag.replace(f'{{{SVG_NS}}}', '') == 'g' for child in root ) background_xml, background_skip_id = ( _extract_background_candidate(root, ctx) if promote_background else ('', None) ) promoted_backgrounds = 1 if background_xml else 0 if background_xml and trace_events is not None: trace_events.append({ 'tag': 'rect', 'decision': 'native-background', 'reason': 'promoted-full-canvas-rect-to-bgPr', }) # Per-element shape ids of every top-level child, used as an animation # fallback when no groups are present at the root. fallback_targets: list = [] for child in root: tag = child.tag.replace(f'{{{SVG_NS}}}', '') if tag == 'defs': continue if id(child) == background_skip_id: continue result = convert_element(child, ctx) if result: shapes.append(result.xml) converted += 1 m = re.search(r' groups are the # preferred anchors (set inside convert_g). When the SVG has none # at the root we fall back to top-level primitives, but only when # the count is reasonable. Presenter-click animation should reveal # semantic blocks, not atomized drawing primitives, so fallback is # intentionally capped at a low count. _ANIM_FALLBACK_CAP = 8 if ( not has_top_level_group and not ctx.anim_targets and 0 < len(fallback_targets) <= _ANIM_FALLBACK_CAP ): ctx.anim_targets = fallback_targets if verbose: promoted = ( f', promoted {promoted_backgrounds} background' if promoted_backgrounds else '' ) print(f' Converted {converted} elements, skipped {skipped}{promoted}') if trace_out is not None: trace_out.append({ 'slide_num': slide_num, 'svg': str(svg_path), 'page_role': root.get('data-pptx-page-role'), 'summary': { 'converted': converted, 'skipped': skipped, 'promoted_backgrounds': promoted_backgrounds, 'media_files': len(ctx.media_files), 'package_files': len(ctx.package_files), 'relationships': len(ctx.rel_entries), 'animation_targets': len(ctx.anim_targets), }, 'preprocess': trace_steps, 'events': trace_events or [], }) shapes_xml = '\n'.join(shapes) slide_xml = f''' {background_xml} {shapes_xml} ''' return ( slide_xml, ctx.media_files, ctx.rel_entries, ctx.anim_targets, ctx.package_files, ctx.content_type_overrides, )