1700 lines
58 KiB
Python
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('"', """)
|
|
|
|
|
|
def _xml_text_escape(text: str) -> str:
|
|
return (text.replace("&", "&")
|
|
.replace("<", "<")
|
|
.replace(">", ">"))
|