Files
Templated-PPT-Master/runtime/ppt-master/scripts/svg_to_pptx/drawingml/converter.py
T

1838 lines
65 KiB
Python

"""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 '<unnamed>'
errors.extend(f'{marker_id}: {error}' for error in marker_errors)
continue
marker_id = elem.get('id') or elem.get('data-name') or '<unnamed>'
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('<p:sp>') or not shape.xml.rstrip().endswith('</p:sp>'):
raise SvgNativeConversionError('Native txBody can only attach to p:sp')
closing = shape.xml.rfind('</p:sp>')
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 <g> to DrawingML group shape <p:grpSp>.
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'<p:cNvPr id="(\d+)"', native_result.xml)
if shape_match:
ctx.anim_targets.append((int(shape_match.group(1)), elem_id))
return native_result
if (
elem.get('data-pptx-object') in {'shape', 'connector'}
and elem.get('data-pptx-prst') is not None
):
_require_unchanged_preset_preview(elem)
preserved_text = preserved_native_text_body(elem)
if preserved_text is not None:
geometry_carrier, native_text = preserved_text
geometry_ctx = child_ctx
if transform and not native_subtree_active:
geometry_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,
)
geometry_result = convert_element(geometry_carrier, geometry_ctx)
ctx.sync_from_child(geometry_ctx)
if geometry_result is None:
raise SvgNativeConversionError(
'Logical text shape has no convertible geometry carrier'
)
restored = _append_shape_text(
geometry_result,
native_text,
)
if should_animate_group and elem_id:
shape_match = re.search(r'<p:cNvPr id="(\d+)"', restored.xml)
if shape_match:
ctx.anim_targets.append((int(shape_match.group(1)), elem_id))
return restored
child_results: list[ShapeResult] = []
for child in elem:
result = convert_element(child, child_ctx)
if result:
child_results.append(result)
ctx.sync_from_child(child_ctx)
if not child_results:
return None
# A logical imported preset may contain several render-only SVG detail
# paths, but after those are skipped it owns exactly one native object.
# Flatten that carrier even at the top level; otherwise animation grouping
# would turn one source ``p:sp`` into a ``p:grpSp`` wrapper. Retarget an
# optional animation to the restored leaf shape ID.
logical_native_shape_group = (
elem.get('data-pptx-object') in {'shape', 'connector'}
and (
elem.get('data-pptx-prst') is not None
or elem.get('data-pptx-geometry-kind') == 'custom'
)
)
logical_picture_effect_group = (
filter_id is not None
and is_picture_effect_carrier(elem)
)
explicit_native_group = elem.get('data-pptx-object') == 'group'
if (
len(child_results) == 1
and not explicit_native_group
and (
not should_animate_group
or logical_native_shape_group
or logical_picture_effect_group
)
):
if should_animate_group and elem_id:
shape_match = re.search(r'<p:cNvPr id="(\d+)"', child_results[0].xml)
if shape_match:
ctx.anim_targets.append((int(shape_match.group(1)), elem_id))
return child_results[0]
# Multiple children, or a top-level semantic one-child group: wrap in
# <p:grpSp> 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 <a:off> only; <a:chOff> 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. <g id="p02-title">) so the
# PPTX builder can emit per-element object timing. Only the outermost
# multi-child wrapper qualifies — flattened single-child groups have no
# <p:grpSp> 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'''<p:grpSp>
<p:nvGrpSpPr>
<p:cNvPr id="{group_id}" name="Group {group_id}"/>
<p:cNvGrpSpPr/>
<p:nvPr/>
</p:nvGrpSpPr>
<p:grpSpPr>
<a:xfrm{rot_attr}>
<a:off x="{off_x}" y="{off_y}"/>
<a:ext cx="{group_w}" cy="{group_h}"/>
<a:chOff x="{group_x}" y="{group_y}"/>
<a:chExt cx="{group_w}" cy="{group_h}"/>
</a:xfrm>
{group_effect}
</p:grpSpPr>
{shapes_xml}
</p:grpSp>''', 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 '<a:noFill' in fill_xml:
return ''
return f'<p:bg><p:bgPr>{fill_xml}<a:effectLst/></p:bgPr></p:bg>'
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 <defs> 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('<p:grpSp>'):
return {'output_geometry': 'group', 'fidelity': 'visual-only'}
if xml.startswith('<p:pic>'):
return {'output_geometry': 'picture', 'fidelity': 'native-normalized'}
if xml.startswith('<p:graphicFrame>'):
return {'output_geometry': 'native-object', 'fidelity': 'native-normalized'}
preset_match = re.search(r'<a:prstGeom prst="([^"]+)"', xml)
if preset_match is not None:
preset = preset_match.group(1)
source_preset = elem.get('data-pptx-prst')
is_connector = xml.startswith('<p:cxnSp>')
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'<a:custGeom(?:\s|>)', 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'<p:cNvPr id="(\d+)"', result.xml)
metadata: dict[str, Any] = {}
if shape_match:
metadata['shape_id'] = int(shape_match.group(1))
if result.bounds_emu is not None:
metadata['bounds_emu'] = list(result.bounds_emu)
metadata.update(_geometry_trace_metadata(elem, result))
trace('native', **metadata)
else:
trace('skip', reason='empty-or-non-rendering')
return result
if tag in _NON_VISUAL_TAGS:
trace('skip', reason='non-visual')
return None
trace('unsupported')
raise SvgNativeConversionError(f'Unsupported visual SVG element <{tag}>')
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 <use>
# 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} <use data-icon="..."/> 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 <use> 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 <use href="#..."/> 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 <tspan> (those with x/y/non-zero dy) into independent
# <text> 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 <text>. 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 <tspan> 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 <g id="..."> 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'<p:cNvPr id="(\d+)"', result.xml)
elem_id = child.get('id')
role = child.get('data-pptx-role')
placeholder = child.get('data-pptx-placeholder')
has_explicit_semantics = role is not None or placeholder is not None
structurally_static = (
child.get('data-pptx-layer') is not None
or (
has_explicit_semantics
and is_static_page_frame(role, placeholder)
)
)
legacy_chrome = (
not has_explicit_semantics
and is_chrome_id(elem_id)
)
explicit_legacy_override = (
elem_id is not None
and elem_id in ctx.animation_group_overrides
)
if (
m
and not structurally_static
and (not legacy_chrome or explicit_legacy_override)
):
fallback_targets.append((int(m.group(1)), elem_id or tag))
else:
if tag not in _NON_VISUAL_TAGS:
skipped += 1
unresolved_connector_targets = sorted(
ctx.referenced_shape_ids - ctx.claimed_shape_ids
)
if unresolved_connector_targets:
raise SvgNativeConversionError(
'Connector target shape ids were reserved but not restored: '
+ ', '.join(str(shape_id) for shape_id in unresolved_connector_targets)
)
# Animation target fallback. Semantic <g id="..."> 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'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:cSld>
{background_xml}
<p:spTree>
<p:nvGrpSpPr>
<p:cNvPr id="1" name=""/>
<p:cNvGrpSpPr/><p:nvPr/>
</p:nvGrpSpPr>
<p:grpSpPr>
<a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/>
<a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm>
</p:grpSpPr>
{shapes_xml}
</p:spTree>
</p:cSld>
<p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr>
</p:sld>'''
return (
slide_xml,
ctx.media_files,
ctx.rel_entries,
ctx.anim_targets,
ctx.package_files,
ctx.content_type_overrides,
)