"""Per-slide composition: dispatches every ShapeNode through the right converter, accumulates , and produces one final SVG string. The output structure mirrors what svg_to_pptx expects so the deck can be round-tripped: (slide background, if any) ... ... ... Each top-level wraps one shape and is treated by svg_to_pptx as an animation anchor. """ from __future__ import annotations import base64 import copy import hashlib import json from dataclasses import dataclass, field from xml.etree import ElementTree as ET from pptx_shapes import ( CONNECTOR_PRESET_TYPES, NATIVE_FALLBACK_SHA256_ATTR, has_relationship_attributes, svg_native_fallback_markup_fingerprint, svg_text_fingerprint, ) from pptx_effects import ( EFFECT_REASON_ATTR, EFFECT_STATUS_ATTR, txbody_has_run_effects, unsupported_effect_metadata, ) from .color_resolver import ColorPalette, find_color_elem, resolve_color from .chart_to_svg import CHART_URI, CHARTEX_URI, extract_native_chart_payload from .custgeom_to_svg import convert_custom_geom from .effect_to_svg import ( EffectResult, convert_effects, unsupported_target_effect_metadata, ) from .emu_units import NS, Xfrm, fmt_num, format_canvas_px_from_emu from .fill_to_svg import FillResult, resolve_fill from .import_diagnostics import ( ImportDiagnostic, append_diagnostic, ) from .ln_to_svg import StrokeResult, resolve_stroke from .ooxml_loader import ( OoxmlPackage, PartRef, SlideRef, inherited_shape_visibility, ) from .pic_to_svg import ( MediaResolutionError, PictureResult, convert_blip_fill, convert_picture, ) from .prstgeom_to_svg import GeomResult, convert_prst_geom from .preset_svg_markup import serialize_preset_layers from .shape_walker import ( CONNECTOR, GRAPHIC, GROUP, PICTURE, SHAPE, ShapeNode, get_background, walk_sp_tree, ) from .tbl_to_svg import convert_tbl from .txbody_to_svg import ( TextResult, convert_txbody, convert_vertical_txbody, is_vertical_txbody, DEFAULT_FONT_SIZE_PX, ) # --------------------------------------------------------------------------- # AssemblyContext # --------------------------------------------------------------------------- @dataclass class AssemblyContext: """Per-slide accumulator for unique IDs + media + defs.""" palette: ColorPalette | None pkg: OoxmlPackage slide_part: PartRef slide_number: int | None = None theme_fonts: dict[str, str] = field(default_factory=dict) media_subdir: str = "assets" embed_images: bool = False keep_hidden: bool = False strict: bool = False group_id_prefix: str = "" render_graphic_previews: bool = True asset_name_map: dict[str, str] = field(default_factory=dict) diagnostics: list[ImportDiagnostic] = field(default_factory=list) source_slide_index: int | None = None current_node: ShapeNode | None = None # Sequence counters (single-element lists so handlers can mutate) grad_seq: list[int] = field(default_factory=lambda: [0]) marker_seq: list[int] = field(default_factory=lambda: [0]) filter_seq: list[int] = field(default_factory=lambda: [0]) shape_seq: list[int] = field(default_factory=lambda: [0]) clip_seq: list[int] = field(default_factory=lambda: [0]) # Accumulated outputs defs: list[str] = field(default_factory=list) media: dict[str, bytes] = field(default_factory=dict) def bind_palette(self) -> None: """Route tolerant color diagnostics through the current object context.""" if self.palette is None: return self.palette.strict = self.strict self.palette.diagnostic_sink = self._diagnose_color def diagnose( self, code: str, message: str, fallback: str, *, node: ShapeNode | None = None, ) -> None: """Record one recoverable source-contract violation.""" source_node = node or self.current_node append_diagnostic( self.diagnostics, ImportDiagnostic( code=code, message=message, fallback=fallback, part_path=self.slide_part.path, slide_index=self.source_slide_index, shape_id=source_node.spid if source_node is not None else "", shape_name=source_node.name if source_node is not None else "", shape_kind=source_node.kind if source_node is not None else "", ), ) def _diagnose_color(self, code: str, message: str, fallback: str) -> None: self.diagnose(code, message, fallback) def _diagnose_picture_result( ctx: AssemblyContext, result: PictureResult, ) -> None: """Project recoverable picture losses into the import report.""" for diagnostic in result.diagnostics: ctx.diagnose( diagnostic.code, diagnostic.message, diagnostic.fallback, ) # --------------------------------------------------------------------------- # Public entry # --------------------------------------------------------------------------- def assemble_slide( pkg: OoxmlPackage, slide: SlideRef, palette: ColorPalette | None, *, theme_fonts: dict[str, str] | None = None, media_subdir: str = "assets", embed_images: bool = False, keep_hidden: bool = False, inheritance_mode: str = "flat", asset_name_map: dict[str, str] | None = None, strict: bool = False, diagnostics: list[ImportDiagnostic] | None = None, ) -> tuple[str, dict[str, bytes]]: """Convert one slide to a complete SVG string + media files map. inheritance_mode controls how master/layout shapes are rendered: - "flat" (default): emit the effective visible Master/Layout non-placeholder shapes inline inside the slide SVG, honoring both source ``showMasterSp`` flags. This view is used for round-trip fidelity with svg_to_pptx. - "layered": skip inherited shapes entirely. The slide SVG contains only its own shapes. Callers (e.g. /create-template's PPTX import) render master/layout once each as separate SVGs and record the inheritance graph in inheritance.json. """ ctx = AssemblyContext( palette=palette, pkg=pkg, slide_part=slide.part, slide_number=pkg.first_slide_number + slide.index - 1, theme_fonts=theme_fonts or {}, media_subdir=media_subdir, embed_images=embed_images, keep_hidden=keep_hidden, strict=strict, render_graphic_previews=(inheritance_mode == "flat"), asset_name_map=asset_name_map or {}, diagnostics=diagnostics if diagnostics is not None else [], source_slide_index=slide.index, ) ctx.bind_palette() canvas_w, canvas_h = pkg.slide_size_px canvas_w_token, canvas_h_token = ( format_canvas_px_from_emu(value) for value in pkg.slide_size_emu ) # Background (cSld/bg) — emit as the first body element. body_parts: list[str] = [] try: bg_xml = ( _emit_background(slide, ctx, canvas_w, canvas_h) if inheritance_mode == "flat" else _emit_part_background( SlideRef(index=slide.index, part=slide.part, layout=None, master=slide.master), ctx, canvas_w, canvas_h, ) ) except (ValueError, MediaResolutionError) as exc: if strict: raise ctx.diagnose( "background-omitted", str(exc), "omit the unsupported background and continue the slide", ) bg_xml = "" if bg_xml: body_parts.append(bg_xml) if inheritance_mode == "flat": # Inherited layout/master shapes render behind slide-local shapes. Skip # placeholders; they define editable regions, not visible background. body_parts.extend(_emit_inherited_shapes(slide, ctx)) elif inheritance_mode != "layered": raise ValueError( f"inheritance_mode must be 'flat' or 'layered', got {inheritance_mode!r}" ) # Walk shapes — placeholders without their own xfrm inherit geometry from # layout, then master. nodes = walk_sp_tree( slide.part.xml, layout_xml=slide.layout.xml if slide.layout else None, master_xml=slide.master.xml if slide.master else None, ) for node in nodes: chunk = _convert_node(node, ctx, top_level=True) if chunk: body_parts.append(chunk) # Compose final SVG defs_xml = "".join(ctx.defs) if ctx.defs else "" defs_block = f"{defs_xml}" if defs_xml else "" svg = ( f'' f"{defs_block}" + "\n".join(body_parts) + "" ) return svg, ctx.media def assemble_part_solo( pkg: OoxmlPackage, part: PartRef, palette: ColorPalette | None, *, role: str, parent_master: PartRef | None = None, theme_fonts: dict[str, str] | None = None, media_subdir: str = "assets", embed_images: bool = False, keep_hidden: bool = False, asset_name_map: dict[str, str] | None = None, strict: bool = False, diagnostics: list[ImportDiagnostic] | None = None, ) -> tuple[str, dict[str, bytes]]: """Render a single slideMaster or slideLayout part as a standalone SVG. Used by the layered export path. Skips placeholders the same way `_emit_inherited_shapes` does, so the output represents the part's decorative / structural shapes only — what the part *contributes* to its descendants. The first ancestor's background (if any) is emitted as the first body element so the output reads like a real slide. Args: role: 'master' or 'layout'. Used as the group_id_prefix to keep ids unique when the workspace inlines multiple parts in a viewer. parent_master: when ``role == "layout"``, pass the parent slide master so theme-style background fills (````) can resolve via the theme attached to that master. For ``role == "master"`` the master is its own parent and this argument is ignored. """ if role not in {"master", "layout"}: raise ValueError(f"role must be 'master' or 'layout', got {role!r}") ctx = AssemblyContext( palette=palette, pkg=pkg, slide_part=part, theme_fonts=theme_fonts or {}, media_subdir=media_subdir, embed_images=embed_images, keep_hidden=keep_hidden, strict=strict, group_id_prefix=f"{role}-", render_graphic_previews=False, asset_name_map=asset_name_map or {}, diagnostics=diagnostics if diagnostics is not None else [], ) ctx.bind_palette() canvas_w, canvas_h = pkg.slide_size_px canvas_w_token, canvas_h_token = ( format_canvas_px_from_emu(value) for value in pkg.slide_size_emu ) body_parts: list[str] = [] # Layered semantics: each part's standalone SVG must contain only that # part's own contribution. The master gets its own bg, the layout gets # its own bg only if it overrides the master's, and consumers re-stack # the layers when they need a flat view. We therefore inspect on # this part alone — never inherited from above. Theme-style fills # () still need the parent master's to # resolve, hence the SlideRef.master plumbing below. if role == "master": master_for_theme: PartRef | None = part else: master_for_theme = parent_master fake_slide = SlideRef( index=0, part=part, layout=None, master=master_for_theme, ) try: bg_xml = _emit_part_background(fake_slide, ctx, canvas_w, canvas_h) except (ValueError, MediaResolutionError) as exc: if strict: raise ctx.diagnose( "background-omitted", str(exc), "omit the unsupported background and continue the part", ) bg_xml = "" if bg_xml: body_parts.append(bg_xml) # Walk shapes. Layered master/layout SVGs retain each placeholder's source # appearance so mirror materialization can recover its editable decoration. for node in walk_sp_tree(part.xml): if _is_placeholder_node(node): chunk = _convert_placeholder_guide(node, ctx, top_level=True) else: chunk = _convert_node(node, ctx, top_level=True) if chunk: body_parts.append(chunk) defs_xml = "".join(ctx.defs) if ctx.defs else "" defs_block = f"{defs_xml}" if defs_xml else "" svg = ( f'' f"{defs_block}" + "\n".join(body_parts) + "" ) return svg, ctx.media # --------------------------------------------------------------------------- # Per-node dispatch # --------------------------------------------------------------------------- def _convert_node(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool) -> str: previous_node = ctx.current_node ctx.current_node = node try: if node.hidden and not ctx.keep_hidden: return "" if node.kind == SHAPE: return _convert_shape(node, ctx, top_level=top_level) if node.kind == PICTURE: return _convert_picture(node, ctx, top_level=top_level) if node.kind == CONNECTOR: return _convert_connector(node, ctx, top_level=top_level) if node.kind == GROUP: return _convert_group(node, ctx, top_level=top_level) if node.kind == GRAPHIC: return _convert_graphic_fallback(node, ctx, top_level=top_level) return "" except ValueError as exc: if ctx.strict: raise ctx.diagnose( "object-replaced", str(exc), "replace only this object with a visible placeholder", node=node, ) return _fallback_node_svg(node, ctx, top_level=top_level) finally: ctx.current_node = previous_node def _fallback_node_svg( node: ShapeNode, ctx: AssemblyContext, *, top_level: bool, ) -> str: """Keep one unsupported source object visible without aborting its deck.""" if node.xfrm.w <= 0 or node.xfrm.h <= 0: return "" x = fmt_num(node.xfrm.x) y = fmt_num(node.xfrm.y) width = fmt_num(node.xfrm.w) height = fmt_num(node.xfrm.h) label = _xml_escape(node.name or f"Unsupported {node.kind}") inner = ( f'' f'{label}' ) return _wrap_shape_group(inner, node, ctx, top_level=top_level) # --------------------------------------------------------------------------- # Shape () # --------------------------------------------------------------------------- def _convert_shape(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool) -> str: sp_pr = node.xml.find("p:spPr", NS) # Check for blipFill (image-filled shape, e.g. Canva exports where images # are expressed as + rather than ). geom = _resolve_geometry(node, sp_pr) blip_fill_elem = sp_pr.find("a:blipFill", NS) if sp_pr is not None else None blip_image = "" if blip_fill_elem is not None: try: blip_result = convert_blip_fill( blip_fill_elem, node.xfrm, ctx.slide_part, ctx.pkg, media_subdir=ctx.media_subdir, embed_inline=ctx.embed_images, asset_name_map=ctx.asset_name_map, strict=ctx.strict, ) except (ValueError, MediaResolutionError) as exc: if ctx.strict: raise ctx.diagnose( "image-fill-omitted", str(exc), "omit the image fill and retain shape geometry/text", ) else: _diagnose_picture_result(ctx, blip_result) if blip_result.svg: blip_image = _clip_blip_image(blip_result.svg, geom, ctx) ctx.media.update(blip_result.media) # Text body (a:txBody) source_tx_body = node.xml.find("p:txBody", NS) tx_body = _effective_placeholder_tx_body( source_tx_body, node.inherited_body_properties, ) is_vertical = is_vertical_txbody(tx_body, node.xfrm) local_has_run_effects = txbody_has_run_effects(source_tx_body) inherited_has_run_effects = txbody_has_run_effects( *node.inherited_lst_styles ) has_run_effects = local_has_run_effects or inherited_has_run_effects if geom is not None and has_run_effects: if is_vertical: geom.attrs.update(unsupported_effect_metadata( "unsupported-run-effect-route:vertical-text" )) elif tx_body is not None and has_relationship_attributes(tx_body): geom.attrs.update(unsupported_effect_metadata( "unsupported-run-effect-route:relationship-bearing-text" )) elif inherited_has_run_effects: geom.attrs.update(unsupported_effect_metadata( "unsupported-run-effect-route:inherited-text-style" )) # Geometry (fill is "none" when blipFill is present, so only stroke draws) geom_xml = _build_geometry_xml(node, sp_pr, ctx, geom=geom) try: text_default_fill = _resolve_text_style_default(node, ctx) if tx_body is not None and is_vertical: text_result = convert_vertical_txbody( tx_body, node.xfrm, ctx.palette, theme_fonts=ctx.theme_fonts, slide_number=ctx.slide_number, default_fill=text_default_fill, default_font_size_px=DEFAULT_FONT_SIZE_PX, fallback_lst_styles=node.inherited_lst_styles, id_prefix=f"{ctx.group_id_prefix}txt", id_seq=ctx.grad_seq, ) else: text_result = convert_txbody( tx_body, node.xfrm, ctx.palette, theme_fonts=ctx.theme_fonts, slide_number=ctx.slide_number, default_fill=text_default_fill, default_font_size_px=DEFAULT_FONT_SIZE_PX, fallback_lst_styles=node.inherited_lst_styles, id_prefix=f"{ctx.group_id_prefix}txt", id_seq=ctx.grad_seq, ) if tx_body is not None else TextResult() except ValueError as exc: if ctx.strict: raise ctx.diagnose( "text-omitted", str(exc), "omit this text body and retain the object's other visuals", ) text_result = TextResult() if text_result.defs: ctx.defs.extend(text_result.defs) if is_vertical: # Vertical text: geometry + image in one group, text in separate group geom_inner = (blip_image + "\n" + geom_xml) if blip_image else geom_xml shape_xml = _wrap_shape_group( geom_inner, node, ctx, top_level=top_level, extra_attrs=_geometry_group_attrs(geom), ) if not text_result.svg: return shape_xml text_group = ( f'\n' f"{text_result.svg}\n" ) return f"{shape_xml}\n{text_group}" # Normal: image (behind) + geometry (stroke) + text (top) inner_parts = [] if blip_image: inner_parts.append(blip_image) if geom_xml: inner_parts.append(geom_xml) if source_tx_body is not None and geom is not None: inner_parts.append( _txbody_metadata( source_tx_body, text_result.svg, ) ) if text_result.svg: inner_parts.append(text_result.svg) inner = "\n".join(inner_parts) if inner_parts else "" return _wrap_shape_group( inner, node, ctx, top_level=top_level, extra_attrs=_geometry_group_attrs(geom), ) def _effective_placeholder_tx_body( tx_body: ET.Element | None, inherited_body_properties: tuple[ET.Element, ...], ) -> ET.Element | None: """Merge inherited placeholder bodyPr settings into one visible text body.""" if tx_body is None or not inherited_body_properties: return tx_body effective = copy.deepcopy(tx_body) body_pr = effective.find("a:bodyPr", NS) if body_pr is None: body_pr = ET.Element(f"{{{NS['a']}}}bodyPr") effective.insert(0, body_pr) child_groups = ( {"prstTxWarp"}, {"noAutofit", "normAutofit", "spAutoFit"}, {"scene3d"}, {"sp3d"}, ) for inherited in inherited_body_properties: for name, value in inherited.attrib.items(): body_pr.attrib.setdefault(name, value) local_names = { child.tag.rsplit("}", 1)[-1] for child in body_pr if isinstance(child.tag, str) } for group in child_groups: if local_names & group: continue inherited_child = next( ( child for child in inherited if isinstance(child.tag, str) and child.tag.rsplit("}", 1)[-1] in group ), None, ) if inherited_child is not None: body_pr.append(copy.deepcopy(inherited_child)) local_names.add(inherited_child.tag.rsplit("}", 1)[-1]) return effective def _txbody_metadata( tx_body: ET.Element, visible_text_svg: str, ) -> str: """Preserve the native text body while its visible SVG remains authoritative.""" if has_relationship_attributes(tx_body): # Relationship ids are part-local and cannot be copied into a newly # generated slide without rebuilding the relationship target. return "" raw = ET.tostring(tx_body, encoding="utf-8") encoded = base64.b64encode(raw).decode("ascii") wrapper = ET.fromstring( f'{visible_text_svg}' ) digest = svg_text_fingerprint(wrapper) return ( '{encoded}' ) def _resolve_geometry(node: ShapeNode, sp_pr: ET.Element | None) -> GeomResult | None: """Resolve a DrawingML shape geometry into an absolute SVG geometry model.""" prst_geom = sp_pr.find("a:prstGeom", NS) if sp_pr is not None else None cust_geom = sp_pr.find("a:custGeom", NS) if sp_pr is not None else None prst = prst_geom.attrib.get("prst", "rect") if prst_geom is not None else None geom: GeomResult | None = None if prst_geom is not None: geom = convert_prst_geom(prst, node.xfrm, prst_geom) elif cust_geom is not None: d = convert_custom_geom(cust_geom, node.xfrm) if d: raw = ET.tostring(cust_geom, encoding="utf-8") geom = GeomResult( tag="path", path_d=d, attrs={ "data-pptx-part": "geometry", "data-pptx-geometry-kind": "custom", "data-pptx-custgeom": base64.b64encode(raw).decode("ascii"), "data-pptx-geometry-sha256": hashlib.sha256( d.strip().encode("utf-8") ).hexdigest(), }, ) else: # No geometry hint at all — render bounding rect geom = convert_prst_geom("rect", node.xfrm, None) if geom is None: return None permits_degenerate_axis = ( node.kind == CONNECTOR or prst in CONNECTOR_PRESET_TYPES ) if ( not permits_degenerate_axis and (node.xfrm.w <= 0 or node.xfrm.h <= 0) ): return None return geom def _build_geometry_xml(node: ShapeNode, sp_pr: ET.Element | None, ctx: AssemblyContext, geom: GeomResult | None = None) -> str: """Build the SVG geometry element with fill/stroke/effect attributes.""" if geom is None: geom = _resolve_geometry(node, sp_pr) if geom is None: return "" # Resolve style defaults early so markers can adopt the theme stroke color # when doesn't carry an explicit solidFill. try: style_defaults = _resolve_shape_style_defaults(node, ctx) except ValueError as exc: if ctx.strict: raise ctx.diagnose( "shape-style-omitted", str(exc), "omit unresolved theme style defaults", ) style_defaults = {} # Fill / stroke / effect try: fill = resolve_fill( sp_pr, ctx.palette, id_prefix="g", id_seq=ctx.grad_seq, ) except ValueError as exc: if ctx.strict: raise ctx.diagnose( "fill-omitted", str(exc), "omit only the unsupported fill", ) fill = FillResult.none_fill() try: stroke = resolve_stroke( sp_pr, ctx.palette, id_prefix="m", id_seq=ctx.marker_seq, style_stroke_default=style_defaults.get("stroke"), ) except ValueError as exc: if ctx.strict: raise ctx.diagnose( "stroke-omitted", str(exc), "omit only the unsupported outline", ) stroke = StrokeResult(attrs={"stroke": "none"}) try: effect = convert_effects( sp_pr, ctx.palette, id_prefix="fx", id_seq=ctx.filter_seq, target_rotation_degrees=node.effective_rotation, ) except ValueError as exc: if ctx.strict: raise ctx.diagnose( "effect-omitted", str(exc), "omit only the unsupported visual effect", ) effect = EffectResult() ctx.defs.extend(fill.defs) ctx.defs.extend(stroke.defs) ctx.defs.extend(effect.defs) effect_attrs = dict(effect.metadata) effect_reason = effect_attrs.get(EFFECT_REASON_ATTR) existing_reason = geom.attrs.get(EFFECT_REASON_ATTR) if effect_reason is not None and existing_reason is not None: effect_attrs.update(unsupported_effect_metadata( existing_reason, effect_reason, )) geom.attrs.update(effect_attrs) _diagnose_unsupported_effect(ctx, geom.attrs) attrs = {**fill.attrs, **stroke.attrs} for key, value in style_defaults.items(): attrs.setdefault(key, value) if effect.filter_id is not None: attrs["filter"] = f"url(#{effect.filter_id})" # Default fill / stroke when not specified by spPr (matches PowerPoint # behavior: a:noFill on shape-level fill if there's a txBody, else any # explicit fill present in spPr should already have been captured). if "fill" not in attrs: attrs["fill"] = "none" if "stroke" not in attrs: # Spec default for shapes is no stroke unless ln says otherwise. # Skip emitting stroke="none" to keep markup tight. pass semantic_attrs = { **geom.attrs, **_object_metadata(node, ctx), } shape_style = node.xml.find("p:style", NS) if shape_style is not None: semantic_attrs["data-pptx-shape-style"] = base64.b64encode( ET.tostring(shape_style, encoding="utf-8") ).decode("ascii") if geom.layers: return _preset_layers_to_svg(geom, semantic_attrs, attrs) return _geom_to_svg( geom, _attrs_to_xml({**semantic_attrs, **attrs}), ) def _resolve_shape_style_defaults(node: ShapeNode, ctx: AssemblyContext) -> dict[str, str]: """Resolve minimal p:style defaults used when spPr omits explicit style. Full theme style matrix reproduction is intentionally out of scope here; this only prevents common theme-styled placeholders/shapes from becoming transparent or unstroked when their visible color lives in p:style. """ style = node.xml.find("p:style", NS) if style is None: return {} defaults: dict[str, str] = {} fill_ref = style.find("a:fillRef", NS) fill_color = _resolve_ref_color(fill_ref, ctx) if fill_color: defaults["fill"] = fill_color ln_ref = style.find("a:lnRef", NS) line_color = _resolve_ref_color(ln_ref, ctx) if line_color: defaults["stroke"] = line_color defaults.setdefault("stroke-width", "1") return defaults def _resolve_text_style_default(node: ShapeNode, ctx: AssemblyContext) -> str: """Resolve p:style fontRef color used by runs without explicit fill.""" style = node.xml.find("p:style", NS) if style is None: return "#000000" font_ref = style.find("a:fontRef", NS) font_color = _resolve_ref_color(font_ref, ctx) return font_color or "#000000" def _resolve_ref_color(ref_elem: ET.Element | None, ctx: AssemblyContext) -> str | None: color_elem = find_color_elem(ref_elem) hex_, _alpha = resolve_color(color_elem, ctx.palette) return hex_ def _geom_to_svg(geom: GeomResult, attrs_xml: str | None = None) -> str: """Serialize a resolved geometry with optional SVG attributes.""" if attrs_xml is None: attrs_xml = _attrs_to_xml(geom.attrs) if geom.tag == "path": return f'' if geom.tag in ("polygon", "polyline"): return f'<{geom.tag} points="{geom.points}"{attrs_xml}/>' return f"<{geom.tag}{attrs_xml}/>" def _preset_layers_to_svg( geom: GeomResult, semantic_attrs: dict[str, str], style_attrs: dict[str, str], ) -> str: """Serialize one semantic carrier plus every visible preset path layer. DrawingML applies shape-level fill/line first, then each preset path can override whether and how that paint is used. A hidden carrier retains the unmodified shape-level style for native round-trip; visible detail paths reproduce the preset's independent paint behavior without being exported as duplicate PowerPoint shapes. """ markup = serialize_preset_layers( geom.layers, semantic_attrs, style_attrs, ) geom.attrs["data-pptx-preview-sha256"] = markup.preview_hash semantic_attrs["data-pptx-preview-sha256"] = markup.preview_hash return markup.markup def _clip_blip_image(image_xml: str, geom: GeomResult | None, ctx: AssemblyContext) -> str: """Clip image fills to the owning shape geometry when it is not a plain rect.""" if geom is None or geom.tag == "line": return image_xml if geom.attrs.get("data-pptx-prst") == "rect": return image_xml if geom.tag == "rect" and not geom.attrs.get("rx") and not geom.attrs.get("ry"): return image_xml ctx.clip_seq[0] += 1 clip_id = f"{ctx.group_id_prefix}clip{ctx.clip_seq[0]}" clip_shape = _geom_to_svg(geom, "") ctx.defs.append( f'' f'{clip_shape}' ) return _inject_clip_path(image_xml, clip_id) def _inject_clip_path(image_xml: str, clip_id: str) -> str: clip_attr = f' clip-path="url(#{clip_id})"' if image_xml.startswith(") # --------------------------------------------------------------------------- def _convert_picture(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool) -> str: sp_pr = node.xml.find("p:spPr", NS) geom = _resolve_geometry(node, sp_pr) try: result = convert_picture( node.xml, node.xfrm, ctx.slide_part, ctx.pkg, media_subdir=ctx.media_subdir, embed_inline=ctx.embed_images, asset_name_map=ctx.asset_name_map, strict=ctx.strict, ) except MediaResolutionError as exc: if ctx.strict: raise ctx.diagnose( "object-replaced", str(exc), "replace only this picture with a visible placeholder", ) return _fallback_node_svg(node, ctx, top_level=top_level) if not result.svg: return "" _diagnose_picture_result(ctx, result) ctx.media.update(result.media) effect = convert_effects( sp_pr, ctx.palette, id_prefix="fx", id_seq=ctx.filter_seq, target_rotation_degrees=node.effective_rotation, ) ctx.defs.extend(effect.defs) effect_metadata = dict(effect.metadata) _diagnose_unsupported_effect(ctx, effect_metadata) clipped_svg = _clip_blip_image(result.svg, geom, ctx) picture_attrs = {**_object_metadata(node, ctx), **effect_metadata} group_attrs = _metadata_group_attrs(effect_metadata) if effect.filter_id is not None: filter_attr = f"url(#{effect.filter_id})" if ( clipped_svg.startswith(" str: """Attach source-object identity to a picture's root SVG element.""" attrs_xml = _attrs_to_xml(attrs) for tag in ("image", "svg"): prefix = f"<{tag}" if markup.startswith(prefix): return markup.replace(prefix, f"{prefix}{attrs_xml}", 1) return markup # --------------------------------------------------------------------------- # Connector () # --------------------------------------------------------------------------- def _convert_connector(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool) -> str: sp_pr = node.xml.find("p:spPr", NS) geom = _resolve_geometry(node, sp_pr) geom_xml = _build_geometry_xml(node, sp_pr, ctx, geom=geom) return _wrap_shape_group( geom_xml, node, ctx, top_level=top_level, extra_attrs=_geometry_group_attrs(geom), ) # --------------------------------------------------------------------------- # Group () # --------------------------------------------------------------------------- def _convert_group(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool) -> str: """Render group contents flat (children already remapped to slide space).""" inner_parts: list[str] = [] for child in node.children: chunk = _convert_node(child, ctx, top_level=False) if chunk: inner_parts.append(chunk) if not inner_parts: return "" inner = "\n".join(inner_parts) effect_metadata = unsupported_target_effect_metadata( node.xml.find("p:grpSpPr", NS), "group", ) _diagnose_unsupported_effect(ctx, effect_metadata) return _wrap_shape_group( inner, node, ctx, top_level=top_level, extra_attrs=_metadata_group_attrs(effect_metadata), ) # --------------------------------------------------------------------------- # Graphic frame fallback () # --------------------------------------------------------------------------- def _convert_graphic_fallback(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool) -> str: """Render a by dispatching on its graphicData uri. Currently: - ``...drawingml/2006/table`` → real table renderer (`convert_tbl`) - ``...presentationml/2006/ole`` → render the ``mc:Fallback`` preview bitmap that PowerPoint bakes alongside every embedded OLE object. Visually identical to what PowerPoint shows for an unedited embed. - supported classic charts → baked preview plus native chart metadata. - everything else (SmartArt / diagram / unsupported chart) → labelled preview or bounding rectangle plus transparent unsupported metadata. """ graphic_data = node.xml.find("a:graphic/a:graphicData", NS) uri = graphic_data.attrib.get("uri", "graphicFrame") if graphic_data is not None else "graphicFrame" if uri == "http://schemas.openxmlformats.org/drawingml/2006/table": rendered, replacement_attrs, payload_metadata = _render_graphic_table( node, ctx, graphic_data, ) if rendered: inner = ( f"{payload_metadata}\n{rendered}" if payload_metadata else rendered ) return _wrap_shape_group( inner, node, ctx, top_level=top_level, extra_attrs=replacement_attrs, ) preview_svg = "" if ctx.render_graphic_previews: try: preview_svg = _render_graphic_preview(node, ctx) except MediaResolutionError as exc: if ctx.strict: raise ctx.diagnose( "preview-omitted", str(exc), "omit the missing baked preview and retain the native, " "normalized, or placeholder fallback", ) chart_replacement_attrs: list[str] = [] chart_payload_metadata = "" if uri in {CHART_URI, CHARTEX_URI}: rendered, chart_replacement_attrs, chart_payload_metadata = ( _render_graphic_chart( node, ctx, graphic_data, preview_svg, ) ) if rendered: inner = ( f"{chart_payload_metadata}\n{rendered}" if chart_payload_metadata else rendered ) return _wrap_shape_group( inner, node, ctx, top_level=top_level, extra_attrs=chart_replacement_attrs, ) if uri == "http://schemas.openxmlformats.org/presentationml/2006/ole": if preview_svg: labelled = ( preview_svg + "\n" + _graphic_preview_label(node, "ole preview") ) return _wrap_shape_group(labelled, node, ctx, top_level=top_level) if preview_svg: labelled = ( preview_svg + "\n" + _graphic_preview_label( node, f"{uri.rsplit('/', 1)[-1]} preview", ) ) return _wrap_shape_group(labelled, node, ctx, top_level=top_level) label = uri.rsplit("/", 1)[-1] placeholder = ( f'' f'' f"[{_xml_escape(label)}]" ) if chart_payload_metadata: placeholder = f"{chart_payload_metadata}\n{placeholder}" return _wrap_shape_group( placeholder, node, ctx, top_level=top_level, extra_attrs=chart_replacement_attrs, ) def _graphic_preview_label(node: ShapeNode, label: str) -> str: return ( f'' f'[{_xml_escape(label)}]' ) def _replacement_payload_metadata(payload: object) -> str: payload_json = json.dumps( payload, allow_nan=False, ensure_ascii=False, separators=(",", ":"), ) return ( '' f'{_xml_text_escape(payload_json)}' ) def _render_graphic_table( node: ShapeNode, ctx: AssemblyContext, graphic_data: ET.Element | None, ) -> tuple[str, list[str], str]: """Convert the child of a graphicFrame to SVG plus metadata.""" if graphic_data is None: return "", [], "" tbl = graphic_data.find("a:tbl", NS) if tbl is None: return "", [], "" table_styles_part = ctx.pkg.resolve_table_styles() result = convert_tbl( tbl, node.xfrm, ctx.palette, table_styles=( table_styles_part.xml if table_styles_part is not None else None ), theme_fonts=ctx.theme_fonts, slide_number=ctx.slide_number, id_prefix=f"tbl{ctx.shape_seq[0]}", grad_seq=ctx.grad_seq, marker_seq=ctx.marker_seq, ) if result.defs: ctx.defs.extend(result.defs) replacement_attrs: list[str] = ['data-pptx-import-source="pptx"'] payload_metadata = "" if result.native_payload: if node.name and not result.native_payload.get("name"): result.native_payload["name"] = node.name payload_metadata = _replacement_payload_metadata(result.native_payload) replacement_attrs.append('data-pptx-replace-with="table"') elif result.native_status: replacement_attrs.append( 'data-pptx-replacement-status="' f'{_xml_escape(result.native_status)}"' ) if result.effect_reason: effect_metadata = unsupported_effect_metadata(result.effect_reason) _diagnose_unsupported_effect(ctx, effect_metadata) replacement_attrs.extend(_metadata_group_attrs(effect_metadata)) return result.svg, replacement_attrs, payload_metadata def _render_graphic_chart( node: ShapeNode, ctx: AssemblyContext, graphic_data: ET.Element | None, preview_svg: str, ) -> tuple[str, list[str], str]: """Return a chart fallback plus native Chart replacement metadata.""" result = extract_native_chart_payload( graphic_data, node.xfrm, ctx.slide_part, ctx.pkg, ctx.palette, ) replacement_attrs: list[str] = ['data-pptx-import-source="pptx"'] payload_metadata = "" if result.native_payload: if node.name and not result.native_payload.get("name"): result.native_payload["name"] = node.name payload_metadata = _replacement_payload_metadata(result.native_payload) replacement_attrs.append('data-pptx-replace-with="chart"') elif result.native_status: replacement_attrs.append( 'data-pptx-replacement-status="' f'{_xml_escape(result.native_status)}"' ) rendered = preview_svg if rendered: replacement_attrs.append('data-pptx-fallback-kind="source-preview"') elif result.normalized_svg: rendered = result.normalized_svg replacement_attrs.append('data-pptx-fallback-kind="normalized"') else: replacement_attrs.append('data-pptx-fallback-kind="placeholder"') return rendered, replacement_attrs, payload_metadata def _render_graphic_preview(node: ShapeNode, ctx: AssemblyContext) -> str: """Render a graphicFrame's baked fallback preview bitmap when present. PowerPoint stores a static raster preview for many embedded graphics inside ``mc:AlternateContent``. The Fallback branch is normally a plain ``p:pic`` (sometimes nested), so any conformant viewer that can't speak the richer object paints the preview. We do the same for flat preview SVGs. Falls back to '' when the deck has no Fallback pic (very old or third-party authoring tools sometimes omit it). Caller then emits the dashed placeholder. """ ac = node.xml.find("a:graphic/a:graphicData/mc:AlternateContent", NS) if ac is None: return "" pic = ac.find("mc:Fallback//p:pic", NS) if pic is None: # Some authoring tools put the preview directly in mc:Choice. pic = ac.find("mc:Choice//p:pic", NS) if pic is None: return "" # The inner pic carries its own absolute xfrm in this deck (and in every # well-formed PPTX I've seen — PowerPoint copies the graphicFrame xfrm # there during save). If it's missing, fall back to the graphicFrame's # xfrm so the preview at least lands somewhere visible. inner_xfrm = node.xfrm pic_xfrm_elem = pic.find("p:spPr/a:xfrm", NS) if pic_xfrm_elem is not None: from .emu_units import parse_xfrm parsed = parse_xfrm(pic_xfrm_elem) if parsed.w > 0 and parsed.h > 0: inner_xfrm = parsed result = convert_picture( pic, inner_xfrm, ctx.slide_part, ctx.pkg, media_subdir=ctx.media_subdir, embed_inline=ctx.embed_images, asset_name_map=ctx.asset_name_map, strict=ctx.strict, ) if not result.svg: return "" _diagnose_picture_result(ctx, result) ctx.media.update(result.media) return result.svg # --------------------------------------------------------------------------- # Background # --------------------------------------------------------------------------- def _emit_background(slide: SlideRef, ctx: AssemblyContext, w: float, h: float) -> str: """Inspect on slide / layout / master in inheritance order.""" for part in (slide.part, slide.layout, slide.master): if part is None: continue bg = get_background(part.xml) if bg is None: continue bg_pr = bg.find("p:bgPr", NS) bg_ref = bg.find("p:bgRef", NS) placeholder_hex = None if bg_pr is None and bg_ref is not None: bg_pr = _theme_background_fill(slide, ctx, bg_ref) color_elem = find_color_elem(bg_ref) placeholder_hex, _ = resolve_color(color_elem, ctx.palette) if bg_pr is None: continue bg_image = _emit_background_image(bg_pr, part, ctx, w, h) if bg_image: return bg_image fill = resolve_fill( bg_pr, ctx.palette, id_prefix="bg", id_seq=ctx.grad_seq, placeholder_hex=placeholder_hex, ) ctx.defs.extend(fill.defs) if not fill.attrs: return "" # Convert dict to attributes attrs_xml = _attrs_to_xml(fill.attrs) return (f'") return "" def _emit_part_background(slide: SlideRef, ctx: AssemblyContext, w: float, h: float) -> str: """Render the background declared on the part itself only. Distinct from `_emit_background`, which walks the slide → layout → master inheritance chain. Used by the layered solo renderer so each standalone master / layout SVG carries only its own ```` — the inheritance is rebuilt by consumers re-stacking the layers, and we'd rather output nothing than have master decoration leak into a layout file. """ bg = get_background(slide.part.xml) if bg is None: return "" bg_pr = bg.find("p:bgPr", NS) bg_ref = bg.find("p:bgRef", NS) placeholder_hex = None if bg_pr is None and bg_ref is not None: bg_pr = _theme_background_fill(slide, ctx, bg_ref) color_elem = find_color_elem(bg_ref) placeholder_hex, _ = resolve_color(color_elem, ctx.palette) if bg_pr is None: return "" bg_image = _emit_background_image(bg_pr, slide.part, ctx, w, h) if bg_image: return bg_image fill = resolve_fill( bg_pr, ctx.palette, id_prefix="bg", id_seq=ctx.grad_seq, placeholder_hex=placeholder_hex, ) ctx.defs.extend(fill.defs) if not fill.attrs: return "" attrs_xml = _attrs_to_xml(fill.attrs) return (f'") def _emit_background_image( bg_pr: ET.Element, source_part: PartRef, ctx: AssemblyContext, w: float, h: float, ) -> str: """Render a slide/layout/master background image fill as a full-canvas image.""" blip_fill = bg_pr.find("a:blipFill", NS) if blip_fill is None: return "" result = convert_blip_fill( blip_fill, Xfrm(0.0, 0.0, w, h), source_part, ctx.pkg, media_subdir=ctx.media_subdir, embed_inline=ctx.embed_images, asset_name_map=ctx.asset_name_map, strict=ctx.strict, ) _diagnose_picture_result(ctx, result) if result.media: ctx.media.update(result.media) return result.svg def _theme_background_fill( slide: SlideRef, ctx: AssemblyContext, bg_ref: ET.Element, ) -> ET.Element | None: """Resolve p:bgRef idx into the theme background fill style list.""" idx_raw = bg_ref.attrib.get("idx") if not idx_raw: return None try: idx = int(idx_raw) except ValueError: return None # ECMA style matrix background fill references are 1001-based. bg_fill_index = idx - 1001 if bg_fill_index < 0: return None theme = ctx.pkg.resolve_theme(slide.master) if theme is None: return None fill_list = theme.xml.find(".//a:fmtScheme/a:bgFillStyleLst", NS) if fill_list is None: return None fills = [child for child in list(fill_list) if isinstance(child.tag, str)] if bg_fill_index >= len(fills): return None return fills[bg_fill_index] def _emit_inherited_shapes(slide: SlideRef, ctx: AssemblyContext) -> list[str]: parts: list[str] = [] show_layout_shapes, show_master_shapes = inherited_shape_visibility(slide) inherited_parts = ( ("master-", slide.master, show_master_shapes), ("layout-", slide.layout, show_layout_shapes), ) for prefix, part, visible in inherited_parts: if part is None or not visible: continue original_part = ctx.slide_part original_prefix = ctx.group_id_prefix ctx.slide_part = part ctx.group_id_prefix = prefix try: for node in walk_sp_tree(part.xml): if _is_placeholder_node(node): continue chunk = _convert_node(node, ctx, top_level=True) if chunk: parts.append(chunk) finally: ctx.slide_part = original_part ctx.group_id_prefix = original_prefix return parts def _is_placeholder_node(node: ShapeNode) -> bool: if node.placeholder is not None: return True if node.kind == GROUP: return all(_is_placeholder_node(child) for child in node.children) return False def _convert_placeholder_guide(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool) -> str: """Emit the source-authored appearance of one template placeholder.""" return _convert_node(node, ctx, top_level=top_level) # --------------------------------------------------------------------------- # Wrap / utilities # --------------------------------------------------------------------------- def _wrap_shape_group( inner: str, node: ShapeNode, ctx: AssemblyContext, *, top_level: bool, extra_attrs: list[str] | None = None, ) -> str: """Wrap a shape's body in a that carries the transform (rotation / flip) and an id for animation anchoring.""" if not inner.strip(): return "" transform = node.xfrm.to_svg_transform() ctx.shape_seq[0] += 1 seq = ctx.shape_seq[0] sid = node.spid or str(seq) g_id = f"{ctx.group_id_prefix}shape-{sid}" attrs: list[str] = [f'id="{g_id}"'] attrs.extend( f'{key}="{_xml_escape(value)}"' for key, value in _object_metadata( node, ctx, fallback_shape_id=sid, ).items() ) if node.name: attrs.append(f'data-name="{_xml_escape(node.name)}"') if node.placeholder is not None and node.placeholder.type: attrs.append(f'data-ph-type="{_xml_escape(node.placeholder.type)}"') if node.placeholder is not None and node.kind == SHAPE: sp_pr = node.xml.find("p:spPr", NS) if sp_pr is not None and any( sp_pr.find(path, NS) is not None for path in ("a:prstGeom", "a:custGeom") ): attrs.append('data-pptx-placeholder-local-geometry="true"') if extra_attrs: attrs.extend(extra_attrs) if any( attribute.split("=", 1)[0] == "data-pptx-replace-with" for attribute in extra_attrs ): fallback_hash = svg_native_fallback_markup_fingerprint( inner, root_transform=transform, external_markup="".join(ctx.defs), ) attrs.append( f'{NATIVE_FALLBACK_SHA256_ATTR}="{fallback_hash}"' ) if transform: attrs.append(f'transform="{transform}"') return f"\n{inner}\n" def _attrs_to_xml(attrs: dict[str, str]) -> str: if not attrs: return "" return "".join(f' {key}="{_xml_escape(value)}"' for key, value in attrs.items()) def _metadata_group_attrs(attrs: dict[str, str]) -> list[str]: """Serialize import metadata for a logical object wrapper.""" return [ f'{key}="{_xml_escape(value)}"' for key, value in attrs.items() ] def _diagnose_unsupported_effect( ctx: AssemblyContext, metadata: dict[str, str], ) -> None: """Copy an import-only blocking effect marker into the conversion report.""" reason = metadata.get(EFFECT_REASON_ATTR) if reason is None: return ctx.diagnose( "effect-unsupported", reason, "retain the base object and record blocking effect metadata", ) def _geometry_group_attrs(geom: GeomResult | None) -> list[str]: """Mirror native geometry semantics onto the logical shape container.""" if geom is None: return [] keys = ( "data-pptx-prst", "data-pptx-geometry-kind", "data-pptx-geometry-sha256", "data-pptx-preview-sha256", "data-pptx-geometry-status", "data-pptx-geometry-reason", EFFECT_STATUS_ATTR, EFFECT_REASON_ATTR, ) attrs: list[str] = [] for key, value in geom.attrs.items(): if key in keys or key.startswith("data-pptx-av-"): attrs.append(f'{key}="{_xml_escape(value)}"') return attrs def _object_metadata( node: ShapeNode, ctx: AssemblyContext, *, fallback_shape_id: str = "", ) -> dict[str, str]: """Describe the source object without coupling geometry to its SVG bounds.""" object_kind = { SHAPE: "shape", PICTURE: "picture", CONNECTOR: "connector", GROUP: "group", GRAPHIC: "graphic-frame", }.get(node.kind, node.kind) shape_id = node.spid or fallback_shape_id frame = " ".join(( fmt_num(node.xfrm.x, 8), fmt_num(node.xfrm.y, 8), fmt_num(node.xfrm.w, 8), fmt_num(node.xfrm.h, 8), )) attrs = { "data-pptx-object": object_kind, "data-pptx-shape-id": shape_id, "data-pptx-shape-scope": _shape_scope(ctx), "data-pptx-frame": frame, } if node.name: attrs["data-pptx-shape-name"] = node.name if node.kind == CONNECTOR: attrs.update(_connector_metadata(node, _shape_scope(ctx))) return attrs def _shape_scope(ctx: AssemblyContext) -> str: if ctx.group_id_prefix.startswith("master-"): return "master" if ctx.group_id_prefix.startswith("layout-"): return "layout" return "slide" def _connector_metadata(node: ShapeNode, scope: str) -> dict[str, str]: """Preserve connector endpoint references when PowerPoint declares them.""" attrs: dict[str, str] = {} cnv = node.xml.find("p:nvCxnSpPr/p:cNvCxnSpPr", NS) if cnv is None: return attrs for endpoint, prefix in (("stCxn", "start"), ("endCxn", "end")): connection = cnv.find(f"a:{endpoint}", NS) if connection is None: continue shape_id = connection.attrib.get("id") site = connection.attrib.get("idx") if shape_id is not None: attrs[f"data-pptx-{prefix}-shape-id"] = shape_id attrs[f"data-pptx-{prefix}-shape-scope"] = scope if site is not None: attrs[f"data-pptx-{prefix}-site"] = site return attrs def _xml_escape(text: str) -> str: return _xml_text_escape(text).replace('"', """) def _xml_text_escape(text: str) -> str: return (text.replace("&", "&") .replace("<", "<") .replace(">", ">"))