Files

1700 lines
58 KiB
Python

"""Per-slide composition: dispatches every ShapeNode through the right
converter, accumulates <defs>, and produces one final SVG string.
The output structure mirrors what svg_to_pptx expects so the deck can be
round-tripped:
<svg viewBox="0 0 W H">
<defs>
<linearGradient id=.../>
<marker id=.../>
<filter id=.../>
</defs>
<!-- background -->
<rect ... /> (slide background, if any)
<g id="shape-1">...</g>
<g id="shape-2">...</g>
...
</svg>
Each top-level <g> 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>{defs_xml}</defs>" if defs_xml else ""
svg = (
f'<svg xmlns="http://www.w3.org/2000/svg" '
f'xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" '
f'width="{canvas_w_token}" height="{canvas_h_token}" '
f'viewBox="0 0 {canvas_w_token} {canvas_h_token}">'
f"{defs_block}"
+ "\n".join(body_parts)
+ "</svg>"
)
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 (``<p:bgRef idx=...>``)
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 <p:bg> on
# this part alone — never inherited from above. Theme-style fills
# (<p:bgRef idx=...>) still need the parent master's <a:fmtScheme> 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>{defs_xml}</defs>" if defs_xml else ""
svg = (
f'<svg xmlns="http://www.w3.org/2000/svg" '
f'xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" '
f'width="{canvas_w_token}" height="{canvas_h_token}" '
f'viewBox="0 0 {canvas_w_token} {canvas_h_token}">'
f"{defs_block}"
+ "\n".join(body_parts)
+ "</svg>"
)
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'<rect x="{x}" y="{y}" width="{width}" height="{height}" '
'fill="#F8FAFC" fill-opacity="0.72" stroke="#DC2626" '
'stroke-width="1" stroke-dasharray="6 4"/>'
f'<text x="{fmt_num(node.xfrm.x + 8)}" '
f'y="{fmt_num(node.xfrm.y + min(18, node.xfrm.h / 2))}" '
f'font-size="12" fill="#991B1B">{label}</text>'
)
return _wrap_shape_group(inner, node, ctx, top_level=top_level)
# ---------------------------------------------------------------------------
# Shape (<p:sp>)
# ---------------------------------------------------------------------------
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 <p:sp> + <a:blipFill> rather than <p:pic>).
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'<g id="{ctx.group_id_prefix}shape-{node.spid or ctx.shape_seq[0]}-text"'
f' data-name="{_xml_escape(node.name)} text">\n'
f"{text_result.svg}\n</g>"
)
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'<svg xmlns="http://www.w3.org/2000/svg">{visible_text_svg}</svg>'
)
digest = svg_text_fingerprint(wrapper)
return (
'<metadata data-pptx-part="txbody" data-pptx-encoding="base64" '
f'data-pptx-text-sha256="{digest}">{encoded}</metadata>'
)
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 <a:ln> 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'<path d="{geom.path_d}"{attrs_xml}/>'
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'<clipPath id="{clip_id}" clipPathUnits="userSpaceOnUse">'
f'{clip_shape}</clipPath>'
)
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("<image"):
return image_xml.replace("<image", f"<image{clip_attr}", 1)
if image_xml.startswith("<svg"):
return image_xml.replace("<svg", f'<svg data-pptx-crop="1"{clip_attr}', 1)
return image_xml
# ---------------------------------------------------------------------------
# Picture (<p:pic>)
# ---------------------------------------------------------------------------
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("<svg")
or clipped_svg.startswith("<image clip-path=")
):
# Keep the effect outside the crop viewport so shadows and glows
# remain visible beyond the picture geometry in SVG previews.
group_attrs.append(f'filter="{filter_attr}"')
else:
picture_attrs["filter"] = filter_attr
picture_svg = _inject_root_svg_attrs(
clipped_svg,
picture_attrs,
)
return _wrap_shape_group(
picture_svg,
node,
ctx,
top_level=top_level,
extra_attrs=group_attrs,
)
def _inject_root_svg_attrs(markup: str, attrs: dict[str, str]) -> 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 (<p:cxnSp>)
# ---------------------------------------------------------------------------
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 (<p:grpSp>)
# ---------------------------------------------------------------------------
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 (<p:graphicFrame>)
# ---------------------------------------------------------------------------
def _convert_graphic_fallback(node: ShapeNode, ctx: AssemblyContext,
*, top_level: bool) -> str:
"""Render a <p:graphicFrame> 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'<rect x="{fmt_num(node.xfrm.x)}" y="{fmt_num(node.xfrm.y)}" '
f'width="{fmt_num(node.xfrm.w)}" height="{fmt_num(node.xfrm.h)}" '
f'fill="none" stroke="#999999" stroke-dasharray="4 4"/>'
f'<text x="{fmt_num(node.xfrm.x + node.xfrm.w / 2)}" '
f'y="{fmt_num(node.xfrm.y + node.xfrm.h / 2)}" '
f'text-anchor="middle" font-size="14" fill="#999999">'
f"[{_xml_escape(label)}]</text>"
)
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'<rect x="{fmt_num(node.xfrm.x)}" y="{fmt_num(node.xfrm.y)}" '
f'width="{fmt_num(node.xfrm.w)}" height="22" '
f'fill="#FFFFFF" fill-opacity="0.82" stroke="#999999" stroke-width="0.5"/>'
f'<text x="{fmt_num(node.xfrm.x + 6)}" y="{fmt_num(node.xfrm.y + 15)}" '
f'font-size="11" fill="#666666">[{_xml_escape(label)}]</text>'
)
def _replacement_payload_metadata(payload: object) -> str:
payload_json = json.dumps(
payload,
allow_nan=False,
ensure_ascii=False,
separators=(",", ":"),
)
return (
'<metadata type="application/json">'
f'{_xml_text_escape(payload_json)}</metadata>'
)
def _render_graphic_table(
node: ShapeNode,
ctx: AssemblyContext,
graphic_data: ET.Element | None,
) -> tuple[str, list[str], str]:
"""Convert the <a:tbl> 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 <p:bg> 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'<rect x="0" y="0" width="{fmt_num(w)}" height="{fmt_num(h)}"'
f"{attrs_xml}/>")
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 ``<p:bg>`` — 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'<rect x="0" y="0" width="{fmt_num(w)}" height="{fmt_num(h)}"'
f"{attrs_xml}/>")
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 <g> 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"<g {' '.join(attrs)}>\n{inner}\n</g>"
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('"', "&quot;")
def _xml_text_escape(text: str) -> str:
return (text.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;"))