返回 ppt-master
slide_to_svg.py
根目录 / skills / ppt-master / scripts / pptx_to_svg / slide_to_svg.py
1 """Per-slide composition: dispatches every ShapeNode through the right
2 converter, accumulates <defs>, and produces one final SVG string.
3
4 The output structure mirrors what svg_to_pptx expects so the deck can be
5 round-tripped:
6 <svg viewBox="0 0 W H">
7 <defs>
8 <linearGradient id=.../>
9 <marker id=.../>
10 <filter id=.../>
11 </defs>
12 <!-- background -->
13 <rect ... /> (slide background, if any)
14 <g id="shape-1">...</g>
15 <g id="shape-2">...</g>
16 ...
17 </svg>
18
19 Each top-level <g> wraps one shape and is treated by svg_to_pptx as an
20 animation anchor.
21 """
22
23 from __future__ import annotations
24
25 import base64
26 import copy
27 import hashlib
28 import json
29 from dataclasses import dataclass, field
30 from xml.etree import ElementTree as ET
31
32 from pptx_shapes import (
33 CONNECTOR_PRESET_TYPES,
34 NATIVE_FALLBACK_SHA256_ATTR,
35 has_relationship_attributes,
36 svg_native_fallback_markup_fingerprint,
37 svg_text_fingerprint,
38 )
39 from pptx_effects import (
40 EFFECT_REASON_ATTR,
41 EFFECT_STATUS_ATTR,
42 txbody_has_run_effects,
43 unsupported_effect_metadata,
44 )
45
46 from .color_resolver import ColorPalette, find_color_elem, resolve_color
47 from .chart_to_svg import CHART_URI, CHARTEX_URI, extract_native_chart_payload
48 from .custgeom_to_svg import convert_custom_geom
49 from .effect_to_svg import (
50 EffectResult,
51 convert_effects,
52 unsupported_target_effect_metadata,
53 )
54 from .emu_units import NS, Xfrm, fmt_num, format_canvas_px_from_emu
55 from .fill_to_svg import FillResult, resolve_fill
56 from .import_diagnostics import (
57 ImportDiagnostic,
58 append_diagnostic,
59 )
60 from .ln_to_svg import StrokeResult, resolve_stroke
61 from .ooxml_loader import (
62 OoxmlPackage,
63 PartRef,
64 SlideRef,
65 inherited_shape_visibility,
66 )
67 from .pic_to_svg import (
68 MediaResolutionError,
69 PictureResult,
70 convert_blip_fill,
71 convert_picture,
72 )
73 from .prstgeom_to_svg import GeomResult, convert_prst_geom
74 from .preset_svg_markup import serialize_preset_layers
75 from .shape_walker import (
76 CONNECTOR, GRAPHIC, GROUP, PICTURE, SHAPE,
77 ShapeNode, get_background, walk_sp_tree,
78 )
79 from .tbl_to_svg import convert_tbl
80 from .txbody_to_svg import (
81 TextResult,
82 convert_txbody,
83 convert_vertical_txbody,
84 is_vertical_txbody,
85 DEFAULT_FONT_SIZE_PX,
86 )
87
88
89 # ---------------------------------------------------------------------------
90 # AssemblyContext
91 # ---------------------------------------------------------------------------
92
93 @dataclass
94 class AssemblyContext:
95 """Per-slide accumulator for unique IDs + media + defs."""
96
97 palette: ColorPalette | None
98 pkg: OoxmlPackage
99 slide_part: PartRef
100 slide_number: int | None = None
101 theme_fonts: dict[str, str] = field(default_factory=dict)
102 media_subdir: str = "assets"
103 embed_images: bool = False
104 keep_hidden: bool = False
105 strict: bool = False
106 group_id_prefix: str = ""
107 render_graphic_previews: bool = True
108 asset_name_map: dict[str, str] = field(default_factory=dict)
109 diagnostics: list[ImportDiagnostic] = field(default_factory=list)
110 source_slide_index: int | None = None
111 current_node: ShapeNode | None = None
112
113 # Sequence counters (single-element lists so handlers can mutate)
114 grad_seq: list[int] = field(default_factory=lambda: [0])
115 marker_seq: list[int] = field(default_factory=lambda: [0])
116 filter_seq: list[int] = field(default_factory=lambda: [0])
117 shape_seq: list[int] = field(default_factory=lambda: [0])
118 clip_seq: list[int] = field(default_factory=lambda: [0])
119
120 # Accumulated outputs
121 defs: list[str] = field(default_factory=list)
122 media: dict[str, bytes] = field(default_factory=dict)
123
124 def bind_palette(self) -> None:
125 """Route tolerant color diagnostics through the current object context."""
126 if self.palette is None:
127 return
128 self.palette.strict = self.strict
129 self.palette.diagnostic_sink = self._diagnose_color
130
131 def diagnose(
132 self,
133 code: str,
134 message: str,
135 fallback: str,
136 *,
137 node: ShapeNode | None = None,
138 ) -> None:
139 """Record one recoverable source-contract violation."""
140 source_node = node or self.current_node
141 append_diagnostic(
142 self.diagnostics,
143 ImportDiagnostic(
144 code=code,
145 message=message,
146 fallback=fallback,
147 part_path=self.slide_part.path,
148 slide_index=self.source_slide_index,
149 shape_id=source_node.spid if source_node is not None else "",
150 shape_name=source_node.name if source_node is not None else "",
151 shape_kind=source_node.kind if source_node is not None else "",
152 ),
153 )
154
155 def _diagnose_color(self, code: str, message: str, fallback: str) -> None:
156 self.diagnose(code, message, fallback)
157
158
159 def _diagnose_picture_result(
160 ctx: AssemblyContext,
161 result: PictureResult,
162 ) -> None:
163 """Project recoverable picture losses into the import report."""
164 for diagnostic in result.diagnostics:
165 ctx.diagnose(
166 diagnostic.code,
167 diagnostic.message,
168 diagnostic.fallback,
169 )
170
171
172 # ---------------------------------------------------------------------------
173 # Public entry
174 # ---------------------------------------------------------------------------
175
176 def assemble_slide(
177 pkg: OoxmlPackage,
178 slide: SlideRef,
179 palette: ColorPalette | None,
180 *,
181 theme_fonts: dict[str, str] | None = None,
182 media_subdir: str = "assets",
183 embed_images: bool = False,
184 keep_hidden: bool = False,
185 inheritance_mode: str = "flat",
186 asset_name_map: dict[str, str] | None = None,
187 strict: bool = False,
188 diagnostics: list[ImportDiagnostic] | None = None,
189 ) -> tuple[str, dict[str, bytes]]:
190 """Convert one slide to a complete SVG string + media files map.
191
192 inheritance_mode controls how master/layout shapes are rendered:
193 - "flat" (default): emit the effective visible Master/Layout
194 non-placeholder shapes inline inside the slide SVG, honoring both
195 source ``showMasterSp`` flags. This view is used for round-trip
196 fidelity with svg_to_pptx.
197 - "layered": skip inherited shapes entirely. The slide SVG contains
198 only its own shapes. Callers (e.g. /create-template's PPTX import)
199 render master/layout once each as separate SVGs and record the
200 inheritance graph in inheritance.json.
201 """
202 ctx = AssemblyContext(
203 palette=palette,
204 pkg=pkg,
205 slide_part=slide.part,
206 slide_number=pkg.first_slide_number + slide.index - 1,
207 theme_fonts=theme_fonts or {},
208 media_subdir=media_subdir,
209 embed_images=embed_images,
210 keep_hidden=keep_hidden,
211 strict=strict,
212 render_graphic_previews=(inheritance_mode == "flat"),
213 asset_name_map=asset_name_map or {},
214 diagnostics=diagnostics if diagnostics is not None else [],
215 source_slide_index=slide.index,
216 )
217 ctx.bind_palette()
218
219 canvas_w, canvas_h = pkg.slide_size_px
220 canvas_w_token, canvas_h_token = (
221 format_canvas_px_from_emu(value) for value in pkg.slide_size_emu
222 )
223
224 # Background (cSld/bg) — emit as the first body element.
225 body_parts: list[str] = []
226 try:
227 bg_xml = (
228 _emit_background(slide, ctx, canvas_w, canvas_h)
229 if inheritance_mode == "flat"
230 else _emit_part_background(
231 SlideRef(index=slide.index, part=slide.part, layout=None, master=slide.master),
232 ctx, canvas_w, canvas_h,
233 )
234 )
235 except (ValueError, MediaResolutionError) as exc:
236 if strict:
237 raise
238 ctx.diagnose(
239 "background-omitted",
240 str(exc),
241 "omit the unsupported background and continue the slide",
242 )
243 bg_xml = ""
244 if bg_xml:
245 body_parts.append(bg_xml)
246
247 if inheritance_mode == "flat":
248 # Inherited layout/master shapes render behind slide-local shapes. Skip
249 # placeholders; they define editable regions, not visible background.
250 body_parts.extend(_emit_inherited_shapes(slide, ctx))
251 elif inheritance_mode != "layered":
252 raise ValueError(
253 f"inheritance_mode must be 'flat' or 'layered', got {inheritance_mode!r}"
254 )
255
256 # Walk shapes — placeholders without their own xfrm inherit geometry from
257 # layout, then master.
258 nodes = walk_sp_tree(
259 slide.part.xml,
260 layout_xml=slide.layout.xml if slide.layout else None,
261 master_xml=slide.master.xml if slide.master else None,
262 )
263 for node in nodes:
264 chunk = _convert_node(node, ctx, top_level=True)
265 if chunk:
266 body_parts.append(chunk)
267
268 # Compose final SVG
269 defs_xml = "".join(ctx.defs) if ctx.defs else ""
270 defs_block = f"<defs>{defs_xml}</defs>" if defs_xml else ""
271
272 svg = (
273 f'<svg xmlns="http://www.w3.org/2000/svg" '
274 f'xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" '
275 f'width="{canvas_w_token}" height="{canvas_h_token}" '
276 f'viewBox="0 0 {canvas_w_token} {canvas_h_token}">'
277 f"{defs_block}"
278 + "\n".join(body_parts)
279 + "</svg>"
280 )
281 return svg, ctx.media
282
283
284 def assemble_part_solo(
285 pkg: OoxmlPackage,
286 part: PartRef,
287 palette: ColorPalette | None,
288 *,
289 role: str,
290 parent_master: PartRef | None = None,
291 theme_fonts: dict[str, str] | None = None,
292 media_subdir: str = "assets",
293 embed_images: bool = False,
294 keep_hidden: bool = False,
295 asset_name_map: dict[str, str] | None = None,
296 strict: bool = False,
297 diagnostics: list[ImportDiagnostic] | None = None,
298 ) -> tuple[str, dict[str, bytes]]:
299 """Render a single slideMaster or slideLayout part as a standalone SVG.
300
301 Used by the layered export path. Skips placeholders the same way
302 `_emit_inherited_shapes` does, so the output represents the part's
303 decorative / structural shapes only — what the part *contributes* to its
304 descendants. The first ancestor's background (if any) is emitted as the
305 first body element so the output reads like a real slide.
306
307 Args:
308 role: 'master' or 'layout'. Used as the group_id_prefix to keep ids
309 unique when the workspace inlines multiple parts in a viewer.
310 parent_master: when ``role == "layout"``, pass the parent slide
311 master so theme-style background fills (``<p:bgRef idx=...>``)
312 can resolve via the theme attached to that master. For
313 ``role == "master"`` the master is its own parent and this
314 argument is ignored.
315 """
316 if role not in {"master", "layout"}:
317 raise ValueError(f"role must be 'master' or 'layout', got {role!r}")
318
319 ctx = AssemblyContext(
320 palette=palette,
321 pkg=pkg,
322 slide_part=part,
323 theme_fonts=theme_fonts or {},
324 media_subdir=media_subdir,
325 embed_images=embed_images,
326 keep_hidden=keep_hidden,
327 strict=strict,
328 group_id_prefix=f"{role}-",
329 render_graphic_previews=False,
330 asset_name_map=asset_name_map or {},
331 diagnostics=diagnostics if diagnostics is not None else [],
332 )
333 ctx.bind_palette()
334
335 canvas_w, canvas_h = pkg.slide_size_px
336 canvas_w_token, canvas_h_token = (
337 format_canvas_px_from_emu(value) for value in pkg.slide_size_emu
338 )
339
340 body_parts: list[str] = []
341
342 # Layered semantics: each part's standalone SVG must contain only that
343 # part's own contribution. The master gets its own bg, the layout gets
344 # its own bg only if it overrides the master's, and consumers re-stack
345 # the layers when they need a flat view. We therefore inspect <p:bg> on
346 # this part alone — never inherited from above. Theme-style fills
347 # (<p:bgRef idx=...>) still need the parent master's <a:fmtScheme> to
348 # resolve, hence the SlideRef.master plumbing below.
349 if role == "master":
350 master_for_theme: PartRef | None = part
351 else:
352 master_for_theme = parent_master
353 fake_slide = SlideRef(
354 index=0,
355 part=part,
356 layout=None,
357 master=master_for_theme,
358 )
359 try:
360 bg_xml = _emit_part_background(fake_slide, ctx, canvas_w, canvas_h)
361 except (ValueError, MediaResolutionError) as exc:
362 if strict:
363 raise
364 ctx.diagnose(
365 "background-omitted",
366 str(exc),
367 "omit the unsupported background and continue the part",
368 )
369 bg_xml = ""
370 if bg_xml:
371 body_parts.append(bg_xml)
372
373 # Walk shapes. Layered master/layout SVGs retain each placeholder's source
374 # appearance so mirror materialization can recover its editable decoration.
375 for node in walk_sp_tree(part.xml):
376 if _is_placeholder_node(node):
377 chunk = _convert_placeholder_guide(node, ctx, top_level=True)
378 else:
379 chunk = _convert_node(node, ctx, top_level=True)
380 if chunk:
381 body_parts.append(chunk)
382
383 defs_xml = "".join(ctx.defs) if ctx.defs else ""
384 defs_block = f"<defs>{defs_xml}</defs>" if defs_xml else ""
385
386 svg = (
387 f'<svg xmlns="http://www.w3.org/2000/svg" '
388 f'xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" '
389 f'width="{canvas_w_token}" height="{canvas_h_token}" '
390 f'viewBox="0 0 {canvas_w_token} {canvas_h_token}">'
391 f"{defs_block}"
392 + "\n".join(body_parts)
393 + "</svg>"
394 )
395 return svg, ctx.media
396
397
398 # ---------------------------------------------------------------------------
399 # Per-node dispatch
400 # ---------------------------------------------------------------------------
401
402 def _convert_node(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool) -> str:
403 previous_node = ctx.current_node
404 ctx.current_node = node
405 try:
406 if node.hidden and not ctx.keep_hidden:
407 return ""
408 if node.kind == SHAPE:
409 return _convert_shape(node, ctx, top_level=top_level)
410 if node.kind == PICTURE:
411 return _convert_picture(node, ctx, top_level=top_level)
412 if node.kind == CONNECTOR:
413 return _convert_connector(node, ctx, top_level=top_level)
414 if node.kind == GROUP:
415 return _convert_group(node, ctx, top_level=top_level)
416 if node.kind == GRAPHIC:
417 return _convert_graphic_fallback(node, ctx, top_level=top_level)
418 return ""
419 except ValueError as exc:
420 if ctx.strict:
421 raise
422 ctx.diagnose(
423 "object-replaced",
424 str(exc),
425 "replace only this object with a visible placeholder",
426 node=node,
427 )
428 return _fallback_node_svg(node, ctx, top_level=top_level)
429 finally:
430 ctx.current_node = previous_node
431
432
433 def _fallback_node_svg(
434 node: ShapeNode,
435 ctx: AssemblyContext,
436 *,
437 top_level: bool,
438 ) -> str:
439 """Keep one unsupported source object visible without aborting its deck."""
440 if node.xfrm.w <= 0 or node.xfrm.h <= 0:
441 return ""
442 x = fmt_num(node.xfrm.x)
443 y = fmt_num(node.xfrm.y)
444 width = fmt_num(node.xfrm.w)
445 height = fmt_num(node.xfrm.h)
446 label = _xml_escape(node.name or f"Unsupported {node.kind}")
447 inner = (
448 f'<rect x="{x}" y="{y}" width="{width}" height="{height}" '
449 'fill="#F8FAFC" fill-opacity="0.72" stroke="#DC2626" '
450 'stroke-width="1" stroke-dasharray="6 4"/>'
451 f'<text x="{fmt_num(node.xfrm.x + 8)}" '
452 f'y="{fmt_num(node.xfrm.y + min(18, node.xfrm.h / 2))}" '
453 f'font-size="12" fill="#991B1B">{label}</text>'
454 )
455 return _wrap_shape_group(inner, node, ctx, top_level=top_level)
456
457
458 # ---------------------------------------------------------------------------
459 # Shape (<p:sp>)
460 # ---------------------------------------------------------------------------
461
462 def _convert_shape(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool) -> str:
463 sp_pr = node.xml.find("p:spPr", NS)
464
465 # Check for blipFill (image-filled shape, e.g. Canva exports where images
466 # are expressed as <p:sp> + <a:blipFill> rather than <p:pic>).
467 geom = _resolve_geometry(node, sp_pr)
468
469 blip_fill_elem = sp_pr.find("a:blipFill", NS) if sp_pr is not None else None
470 blip_image = ""
471 if blip_fill_elem is not None:
472 try:
473 blip_result = convert_blip_fill(
474 blip_fill_elem, node.xfrm, ctx.slide_part, ctx.pkg,
475 media_subdir=ctx.media_subdir,
476 embed_inline=ctx.embed_images,
477 asset_name_map=ctx.asset_name_map,
478 strict=ctx.strict,
479 )
480 except (ValueError, MediaResolutionError) as exc:
481 if ctx.strict:
482 raise
483 ctx.diagnose(
484 "image-fill-omitted",
485 str(exc),
486 "omit the image fill and retain shape geometry/text",
487 )
488 else:
489 _diagnose_picture_result(ctx, blip_result)
490 if blip_result.svg:
491 blip_image = _clip_blip_image(blip_result.svg, geom, ctx)
492 ctx.media.update(blip_result.media)
493
494 # Text body (a:txBody)
495 source_tx_body = node.xml.find("p:txBody", NS)
496 tx_body = _effective_placeholder_tx_body(
497 source_tx_body,
498 node.inherited_body_properties,
499 )
500 is_vertical = is_vertical_txbody(tx_body, node.xfrm)
501 local_has_run_effects = txbody_has_run_effects(source_tx_body)
502 inherited_has_run_effects = txbody_has_run_effects(
503 *node.inherited_lst_styles
504 )
505 has_run_effects = local_has_run_effects or inherited_has_run_effects
506 if geom is not None and has_run_effects:
507 if is_vertical:
508 geom.attrs.update(unsupported_effect_metadata(
509 "unsupported-run-effect-route:vertical-text"
510 ))
511 elif tx_body is not None and has_relationship_attributes(tx_body):
512 geom.attrs.update(unsupported_effect_metadata(
513 "unsupported-run-effect-route:relationship-bearing-text"
514 ))
515 elif inherited_has_run_effects:
516 geom.attrs.update(unsupported_effect_metadata(
517 "unsupported-run-effect-route:inherited-text-style"
518 ))
519
520 # Geometry (fill is "none" when blipFill is present, so only stroke draws)
521 geom_xml = _build_geometry_xml(node, sp_pr, ctx, geom=geom)
522
523 try:
524 text_default_fill = _resolve_text_style_default(node, ctx)
525 if tx_body is not None and is_vertical:
526 text_result = convert_vertical_txbody(
527 tx_body, node.xfrm, ctx.palette,
528 theme_fonts=ctx.theme_fonts,
529 slide_number=ctx.slide_number,
530 default_fill=text_default_fill,
531 default_font_size_px=DEFAULT_FONT_SIZE_PX,
532 fallback_lst_styles=node.inherited_lst_styles,
533 id_prefix=f"{ctx.group_id_prefix}txt",
534 id_seq=ctx.grad_seq,
535 )
536 else:
537 text_result = convert_txbody(
538 tx_body, node.xfrm, ctx.palette,
539 theme_fonts=ctx.theme_fonts,
540 slide_number=ctx.slide_number,
541 default_fill=text_default_fill,
542 default_font_size_px=DEFAULT_FONT_SIZE_PX,
543 fallback_lst_styles=node.inherited_lst_styles,
544 id_prefix=f"{ctx.group_id_prefix}txt",
545 id_seq=ctx.grad_seq,
546 ) if tx_body is not None else TextResult()
547 except ValueError as exc:
548 if ctx.strict:
549 raise
550 ctx.diagnose(
551 "text-omitted",
552 str(exc),
553 "omit this text body and retain the object's other visuals",
554 )
555 text_result = TextResult()
556 if text_result.defs:
557 ctx.defs.extend(text_result.defs)
558
559 if is_vertical:
560 # Vertical text: geometry + image in one group, text in separate group
561 geom_inner = (blip_image + "\n" + geom_xml) if blip_image else geom_xml
562 shape_xml = _wrap_shape_group(
563 geom_inner,
564 node,
565 ctx,
566 top_level=top_level,
567 extra_attrs=_geometry_group_attrs(geom),
568 )
569 if not text_result.svg:
570 return shape_xml
571 text_group = (
572 f'<g id="{ctx.group_id_prefix}shape-{node.spid or ctx.shape_seq[0]}-text"'
573 f' data-name="{_xml_escape(node.name)} text">\n'
574 f"{text_result.svg}\n</g>"
575 )
576 return f"{shape_xml}\n{text_group}"
577
578 # Normal: image (behind) + geometry (stroke) + text (top)
579 inner_parts = []
580 if blip_image:
581 inner_parts.append(blip_image)
582 if geom_xml:
583 inner_parts.append(geom_xml)
584 if source_tx_body is not None and geom is not None:
585 inner_parts.append(
586 _txbody_metadata(
587 source_tx_body,
588 text_result.svg,
589 )
590 )
591 if text_result.svg:
592 inner_parts.append(text_result.svg)
593 inner = "\n".join(inner_parts) if inner_parts else ""
594 return _wrap_shape_group(
595 inner,
596 node,
597 ctx,
598 top_level=top_level,
599 extra_attrs=_geometry_group_attrs(geom),
600 )
601
602
603 def _effective_placeholder_tx_body(
604 tx_body: ET.Element | None,
605 inherited_body_properties: tuple[ET.Element, ...],
606 ) -> ET.Element | None:
607 """Merge inherited placeholder bodyPr settings into one visible text body."""
608 if tx_body is None or not inherited_body_properties:
609 return tx_body
610 effective = copy.deepcopy(tx_body)
611 body_pr = effective.find("a:bodyPr", NS)
612 if body_pr is None:
613 body_pr = ET.Element(f"{{{NS['a']}}}bodyPr")
614 effective.insert(0, body_pr)
615
616 child_groups = (
617 {"prstTxWarp"},
618 {"noAutofit", "normAutofit", "spAutoFit"},
619 {"scene3d"},
620 {"sp3d"},
621 )
622 for inherited in inherited_body_properties:
623 for name, value in inherited.attrib.items():
624 body_pr.attrib.setdefault(name, value)
625 local_names = {
626 child.tag.rsplit("}", 1)[-1]
627 for child in body_pr
628 if isinstance(child.tag, str)
629 }
630 for group in child_groups:
631 if local_names & group:
632 continue
633 inherited_child = next(
634 (
635 child
636 for child in inherited
637 if isinstance(child.tag, str)
638 and child.tag.rsplit("}", 1)[-1] in group
639 ),
640 None,
641 )
642 if inherited_child is not None:
643 body_pr.append(copy.deepcopy(inherited_child))
644 local_names.add(inherited_child.tag.rsplit("}", 1)[-1])
645 return effective
646
647
648 def _txbody_metadata(
649 tx_body: ET.Element,
650 visible_text_svg: str,
651 ) -> str:
652 """Preserve the native text body while its visible SVG remains authoritative."""
653 if has_relationship_attributes(tx_body):
654 # Relationship ids are part-local and cannot be copied into a newly
655 # generated slide without rebuilding the relationship target.
656 return ""
657 raw = ET.tostring(tx_body, encoding="utf-8")
658 encoded = base64.b64encode(raw).decode("ascii")
659 wrapper = ET.fromstring(
660 f'<svg xmlns="http://www.w3.org/2000/svg">{visible_text_svg}</svg>'
661 )
662 digest = svg_text_fingerprint(wrapper)
663 return (
664 '<metadata data-pptx-part="txbody" data-pptx-encoding="base64" '
665 f'data-pptx-text-sha256="{digest}">{encoded}</metadata>'
666 )
667
668
669 def _resolve_geometry(node: ShapeNode, sp_pr: ET.Element | None) -> GeomResult | None:
670 """Resolve a DrawingML shape geometry into an absolute SVG geometry model."""
671 prst_geom = sp_pr.find("a:prstGeom", NS) if sp_pr is not None else None
672 cust_geom = sp_pr.find("a:custGeom", NS) if sp_pr is not None else None
673 prst = prst_geom.attrib.get("prst", "rect") if prst_geom is not None else None
674
675 geom: GeomResult | None = None
676 if prst_geom is not None:
677 geom = convert_prst_geom(prst, node.xfrm, prst_geom)
678 elif cust_geom is not None:
679 d = convert_custom_geom(cust_geom, node.xfrm)
680 if d:
681 raw = ET.tostring(cust_geom, encoding="utf-8")
682 geom = GeomResult(
683 tag="path",
684 path_d=d,
685 attrs={
686 "data-pptx-part": "geometry",
687 "data-pptx-geometry-kind": "custom",
688 "data-pptx-custgeom": base64.b64encode(raw).decode("ascii"),
689 "data-pptx-geometry-sha256": hashlib.sha256(
690 d.strip().encode("utf-8")
691 ).hexdigest(),
692 },
693 )
694 else:
695 # No geometry hint at all — render bounding rect
696 geom = convert_prst_geom("rect", node.xfrm, None)
697
698 if geom is None:
699 return None
700 permits_degenerate_axis = (
701 node.kind == CONNECTOR
702 or prst in CONNECTOR_PRESET_TYPES
703 )
704 if (
705 not permits_degenerate_axis
706 and (node.xfrm.w <= 0 or node.xfrm.h <= 0)
707 ):
708 return None
709 return geom
710
711
712 def _build_geometry_xml(node: ShapeNode, sp_pr: ET.Element | None,
713 ctx: AssemblyContext,
714 geom: GeomResult | None = None) -> str:
715 """Build the SVG geometry element with fill/stroke/effect attributes."""
716 if geom is None:
717 geom = _resolve_geometry(node, sp_pr)
718 if geom is None:
719 return ""
720
721 # Resolve style defaults early so markers can adopt the theme stroke color
722 # when <a:ln> doesn't carry an explicit solidFill.
723 try:
724 style_defaults = _resolve_shape_style_defaults(node, ctx)
725 except ValueError as exc:
726 if ctx.strict:
727 raise
728 ctx.diagnose(
729 "shape-style-omitted",
730 str(exc),
731 "omit unresolved theme style defaults",
732 )
733 style_defaults = {}
734
735 # Fill / stroke / effect
736 try:
737 fill = resolve_fill(
738 sp_pr,
739 ctx.palette,
740 id_prefix="g",
741 id_seq=ctx.grad_seq,
742 )
743 except ValueError as exc:
744 if ctx.strict:
745 raise
746 ctx.diagnose(
747 "fill-omitted",
748 str(exc),
749 "omit only the unsupported fill",
750 )
751 fill = FillResult.none_fill()
752 try:
753 stroke = resolve_stroke(
754 sp_pr,
755 ctx.palette,
756 id_prefix="m",
757 id_seq=ctx.marker_seq,
758 style_stroke_default=style_defaults.get("stroke"),
759 )
760 except ValueError as exc:
761 if ctx.strict:
762 raise
763 ctx.diagnose(
764 "stroke-omitted",
765 str(exc),
766 "omit only the unsupported outline",
767 )
768 stroke = StrokeResult(attrs={"stroke": "none"})
769 try:
770 effect = convert_effects(
771 sp_pr,
772 ctx.palette,
773 id_prefix="fx",
774 id_seq=ctx.filter_seq,
775 target_rotation_degrees=node.effective_rotation,
776 )
777 except ValueError as exc:
778 if ctx.strict:
779 raise
780 ctx.diagnose(
781 "effect-omitted",
782 str(exc),
783 "omit only the unsupported visual effect",
784 )
785 effect = EffectResult()
786
787 ctx.defs.extend(fill.defs)
788 ctx.defs.extend(stroke.defs)
789 ctx.defs.extend(effect.defs)
790 effect_attrs = dict(effect.metadata)
791 effect_reason = effect_attrs.get(EFFECT_REASON_ATTR)
792 existing_reason = geom.attrs.get(EFFECT_REASON_ATTR)
793 if effect_reason is not None and existing_reason is not None:
794 effect_attrs.update(unsupported_effect_metadata(
795 existing_reason,
796 effect_reason,
797 ))
798 geom.attrs.update(effect_attrs)
799 _diagnose_unsupported_effect(ctx, geom.attrs)
800
801 attrs = {**fill.attrs, **stroke.attrs}
802 for key, value in style_defaults.items():
803 attrs.setdefault(key, value)
804 if effect.filter_id is not None:
805 attrs["filter"] = f"url(#{effect.filter_id})"
806
807 # Default fill / stroke when not specified by spPr (matches PowerPoint
808 # behavior: a:noFill on shape-level fill if there's a txBody, else any
809 # explicit fill present in spPr should already have been captured).
810 if "fill" not in attrs:
811 attrs["fill"] = "none"
812 if "stroke" not in attrs:
813 # Spec default for shapes is no stroke unless ln says otherwise.
814 # Skip emitting stroke="none" to keep markup tight.
815 pass
816
817 semantic_attrs = {
818 **geom.attrs,
819 **_object_metadata(node, ctx),
820 }
821 shape_style = node.xml.find("p:style", NS)
822 if shape_style is not None:
823 semantic_attrs["data-pptx-shape-style"] = base64.b64encode(
824 ET.tostring(shape_style, encoding="utf-8")
825 ).decode("ascii")
826 if geom.layers:
827 return _preset_layers_to_svg(geom, semantic_attrs, attrs)
828 return _geom_to_svg(
829 geom,
830 _attrs_to_xml({**semantic_attrs, **attrs}),
831 )
832
833
834 def _resolve_shape_style_defaults(node: ShapeNode, ctx: AssemblyContext) -> dict[str, str]:
835 """Resolve minimal p:style defaults used when spPr omits explicit style.
836
837 Full theme style matrix reproduction is intentionally out of scope here;
838 this only prevents common theme-styled placeholders/shapes from becoming
839 transparent or unstroked when their visible color lives in p:style.
840 """
841 style = node.xml.find("p:style", NS)
842 if style is None:
843 return {}
844
845 defaults: dict[str, str] = {}
846
847 fill_ref = style.find("a:fillRef", NS)
848 fill_color = _resolve_ref_color(fill_ref, ctx)
849 if fill_color:
850 defaults["fill"] = fill_color
851
852 ln_ref = style.find("a:lnRef", NS)
853 line_color = _resolve_ref_color(ln_ref, ctx)
854 if line_color:
855 defaults["stroke"] = line_color
856 defaults.setdefault("stroke-width", "1")
857
858 return defaults
859
860
861 def _resolve_text_style_default(node: ShapeNode, ctx: AssemblyContext) -> str:
862 """Resolve p:style fontRef color used by runs without explicit fill."""
863 style = node.xml.find("p:style", NS)
864 if style is None:
865 return "#000000"
866 font_ref = style.find("a:fontRef", NS)
867 font_color = _resolve_ref_color(font_ref, ctx)
868 return font_color or "#000000"
869
870
871 def _resolve_ref_color(ref_elem: ET.Element | None, ctx: AssemblyContext) -> str | None:
872 color_elem = find_color_elem(ref_elem)
873 hex_, _alpha = resolve_color(color_elem, ctx.palette)
874 return hex_
875
876
877 def _geom_to_svg(geom: GeomResult, attrs_xml: str | None = None) -> str:
878 """Serialize a resolved geometry with optional SVG attributes."""
879 if attrs_xml is None:
880 attrs_xml = _attrs_to_xml(geom.attrs)
881 if geom.tag == "path":
882 return f'<path d="{geom.path_d}"{attrs_xml}/>'
883 if geom.tag in ("polygon", "polyline"):
884 return f'<{geom.tag} points="{geom.points}"{attrs_xml}/>'
885 return f"<{geom.tag}{attrs_xml}/>"
886
887
888 def _preset_layers_to_svg(
889 geom: GeomResult,
890 semantic_attrs: dict[str, str],
891 style_attrs: dict[str, str],
892 ) -> str:
893 """Serialize one semantic carrier plus every visible preset path layer.
894
895 DrawingML applies shape-level fill/line first, then each preset path can
896 override whether and how that paint is used. A hidden carrier retains the
897 unmodified shape-level style for native round-trip; visible detail paths
898 reproduce the preset's independent paint behavior without being exported
899 as duplicate PowerPoint shapes.
900 """
901 markup = serialize_preset_layers(
902 geom.layers,
903 semantic_attrs,
904 style_attrs,
905 )
906 geom.attrs["data-pptx-preview-sha256"] = markup.preview_hash
907 semantic_attrs["data-pptx-preview-sha256"] = markup.preview_hash
908 return markup.markup
909
910
911 def _clip_blip_image(image_xml: str, geom: GeomResult | None,
912 ctx: AssemblyContext) -> str:
913 """Clip image fills to the owning shape geometry when it is not a plain rect."""
914 if geom is None or geom.tag == "line":
915 return image_xml
916 if geom.attrs.get("data-pptx-prst") == "rect":
917 return image_xml
918 if geom.tag == "rect" and not geom.attrs.get("rx") and not geom.attrs.get("ry"):
919 return image_xml
920
921 ctx.clip_seq[0] += 1
922 clip_id = f"{ctx.group_id_prefix}clip{ctx.clip_seq[0]}"
923 clip_shape = _geom_to_svg(geom, "")
924 ctx.defs.append(
925 f'<clipPath id="{clip_id}" clipPathUnits="userSpaceOnUse">'
926 f'{clip_shape}</clipPath>'
927 )
928 return _inject_clip_path(image_xml, clip_id)
929
930
931 def _inject_clip_path(image_xml: str, clip_id: str) -> str:
932 clip_attr = f' clip-path="url(#{clip_id})"'
933 if image_xml.startswith("<image"):
934 return image_xml.replace("<image", f"<image{clip_attr}", 1)
935 if image_xml.startswith("<svg"):
936 return image_xml.replace("<svg", f'<svg data-pptx-crop="1"{clip_attr}', 1)
937 return image_xml
938
939
940 # ---------------------------------------------------------------------------
941 # Picture (<p:pic>)
942 # ---------------------------------------------------------------------------
943
944 def _convert_picture(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool) -> str:
945 sp_pr = node.xml.find("p:spPr", NS)
946 geom = _resolve_geometry(node, sp_pr)
947 try:
948 result = convert_picture(
949 node.xml, node.xfrm, ctx.slide_part, ctx.pkg,
950 media_subdir=ctx.media_subdir,
951 embed_inline=ctx.embed_images,
952 asset_name_map=ctx.asset_name_map,
953 strict=ctx.strict,
954 )
955 except MediaResolutionError as exc:
956 if ctx.strict:
957 raise
958 ctx.diagnose(
959 "object-replaced",
960 str(exc),
961 "replace only this picture with a visible placeholder",
962 )
963 return _fallback_node_svg(node, ctx, top_level=top_level)
964 if not result.svg:
965 return ""
966 _diagnose_picture_result(ctx, result)
967 ctx.media.update(result.media)
968 effect = convert_effects(
969 sp_pr,
970 ctx.palette,
971 id_prefix="fx",
972 id_seq=ctx.filter_seq,
973 target_rotation_degrees=node.effective_rotation,
974 )
975 ctx.defs.extend(effect.defs)
976 effect_metadata = dict(effect.metadata)
977 _diagnose_unsupported_effect(ctx, effect_metadata)
978 clipped_svg = _clip_blip_image(result.svg, geom, ctx)
979 picture_attrs = {**_object_metadata(node, ctx), **effect_metadata}
980 group_attrs = _metadata_group_attrs(effect_metadata)
981 if effect.filter_id is not None:
982 filter_attr = f"url(#{effect.filter_id})"
983 if (
984 clipped_svg.startswith("<svg")
985 or clipped_svg.startswith("<image clip-path=")
986 ):
987 # Keep the effect outside the crop viewport so shadows and glows
988 # remain visible beyond the picture geometry in SVG previews.
989 group_attrs.append(f'filter="{filter_attr}"')
990 else:
991 picture_attrs["filter"] = filter_attr
992 picture_svg = _inject_root_svg_attrs(
993 clipped_svg,
994 picture_attrs,
995 )
996 return _wrap_shape_group(
997 picture_svg,
998 node,
999 ctx,
1000 top_level=top_level,
1001 extra_attrs=group_attrs,
1002 )
1003
1004
1005 def _inject_root_svg_attrs(markup: str, attrs: dict[str, str]) -> str:
1006 """Attach source-object identity to a picture's root SVG element."""
1007 attrs_xml = _attrs_to_xml(attrs)
1008 for tag in ("image", "svg"):
1009 prefix = f"<{tag}"
1010 if markup.startswith(prefix):
1011 return markup.replace(prefix, f"{prefix}{attrs_xml}", 1)
1012 return markup
1013
1014
1015 # ---------------------------------------------------------------------------
1016 # Connector (<p:cxnSp>)
1017 # ---------------------------------------------------------------------------
1018
1019 def _convert_connector(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool) -> str:
1020 sp_pr = node.xml.find("p:spPr", NS)
1021 geom = _resolve_geometry(node, sp_pr)
1022 geom_xml = _build_geometry_xml(node, sp_pr, ctx, geom=geom)
1023 return _wrap_shape_group(
1024 geom_xml,
1025 node,
1026 ctx,
1027 top_level=top_level,
1028 extra_attrs=_geometry_group_attrs(geom),
1029 )
1030
1031
1032 # ---------------------------------------------------------------------------
1033 # Group (<p:grpSp>)
1034 # ---------------------------------------------------------------------------
1035
1036 def _convert_group(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool) -> str:
1037 """Render group contents flat (children already remapped to slide space)."""
1038 inner_parts: list[str] = []
1039 for child in node.children:
1040 chunk = _convert_node(child, ctx, top_level=False)
1041 if chunk:
1042 inner_parts.append(chunk)
1043 if not inner_parts:
1044 return ""
1045 inner = "\n".join(inner_parts)
1046 effect_metadata = unsupported_target_effect_metadata(
1047 node.xml.find("p:grpSpPr", NS),
1048 "group",
1049 )
1050 _diagnose_unsupported_effect(ctx, effect_metadata)
1051 return _wrap_shape_group(
1052 inner,
1053 node,
1054 ctx,
1055 top_level=top_level,
1056 extra_attrs=_metadata_group_attrs(effect_metadata),
1057 )
1058
1059
1060 # ---------------------------------------------------------------------------
1061 # Graphic frame fallback (<p:graphicFrame>)
1062 # ---------------------------------------------------------------------------
1063
1064 def _convert_graphic_fallback(node: ShapeNode, ctx: AssemblyContext,
1065 *, top_level: bool) -> str:
1066 """Render a <p:graphicFrame> by dispatching on its graphicData uri.
1067
1068 Currently:
1069 - ``...drawingml/2006/table`` → real table renderer (`convert_tbl`)
1070 - ``...presentationml/2006/ole`` → render the ``mc:Fallback`` preview
1071 bitmap that PowerPoint bakes alongside every embedded OLE object.
1072 Visually identical to what PowerPoint shows for an unedited embed.
1073 - supported classic charts → baked preview plus native chart metadata.
1074 - everything else (SmartArt / diagram / unsupported chart) → labelled
1075 preview or bounding rectangle plus transparent unsupported metadata.
1076 """
1077 graphic_data = node.xml.find("a:graphic/a:graphicData", NS)
1078 uri = graphic_data.attrib.get("uri", "graphicFrame") if graphic_data is not None else "graphicFrame"
1079
1080 if uri == "http://schemas.openxmlformats.org/drawingml/2006/table":
1081 rendered, replacement_attrs, payload_metadata = _render_graphic_table(
1082 node,
1083 ctx,
1084 graphic_data,
1085 )
1086 if rendered:
1087 inner = (
1088 f"{payload_metadata}\n{rendered}"
1089 if payload_metadata
1090 else rendered
1091 )
1092 return _wrap_shape_group(
1093 inner,
1094 node,
1095 ctx,
1096 top_level=top_level,
1097 extra_attrs=replacement_attrs,
1098 )
1099
1100 preview_svg = ""
1101 if ctx.render_graphic_previews:
1102 try:
1103 preview_svg = _render_graphic_preview(node, ctx)
1104 except MediaResolutionError as exc:
1105 if ctx.strict:
1106 raise
1107 ctx.diagnose(
1108 "preview-omitted",
1109 str(exc),
1110 "omit the missing baked preview and retain the native, "
1111 "normalized, or placeholder fallback",
1112 )
1113
1114 chart_replacement_attrs: list[str] = []
1115 chart_payload_metadata = ""
1116 if uri in {CHART_URI, CHARTEX_URI}:
1117 rendered, chart_replacement_attrs, chart_payload_metadata = (
1118 _render_graphic_chart(
1119 node,
1120 ctx,
1121 graphic_data,
1122 preview_svg,
1123 )
1124 )
1125 if rendered:
1126 inner = (
1127 f"{chart_payload_metadata}\n{rendered}"
1128 if chart_payload_metadata
1129 else rendered
1130 )
1131 return _wrap_shape_group(
1132 inner,
1133 node,
1134 ctx,
1135 top_level=top_level,
1136 extra_attrs=chart_replacement_attrs,
1137 )
1138
1139 if uri == "http://schemas.openxmlformats.org/presentationml/2006/ole":
1140 if preview_svg:
1141 labelled = (
1142 preview_svg
1143 + "\n"
1144 + _graphic_preview_label(node, "ole preview")
1145 )
1146 return _wrap_shape_group(labelled, node, ctx, top_level=top_level)
1147
1148 if preview_svg:
1149 labelled = (
1150 preview_svg
1151 + "\n"
1152 + _graphic_preview_label(
1153 node,
1154 f"{uri.rsplit('/', 1)[-1]} preview",
1155 )
1156 )
1157 return _wrap_shape_group(labelled, node, ctx, top_level=top_level)
1158
1159 label = uri.rsplit("/", 1)[-1]
1160 placeholder = (
1161 f'<rect x="{fmt_num(node.xfrm.x)}" y="{fmt_num(node.xfrm.y)}" '
1162 f'width="{fmt_num(node.xfrm.w)}" height="{fmt_num(node.xfrm.h)}" '
1163 f'fill="none" stroke="#999999" stroke-dasharray="4 4"/>'
1164 f'<text x="{fmt_num(node.xfrm.x + node.xfrm.w / 2)}" '
1165 f'y="{fmt_num(node.xfrm.y + node.xfrm.h / 2)}" '
1166 f'text-anchor="middle" font-size="14" fill="#999999">'
1167 f"[{_xml_escape(label)}]</text>"
1168 )
1169 if chart_payload_metadata:
1170 placeholder = f"{chart_payload_metadata}\n{placeholder}"
1171 return _wrap_shape_group(
1172 placeholder,
1173 node,
1174 ctx,
1175 top_level=top_level,
1176 extra_attrs=chart_replacement_attrs,
1177 )
1178
1179
1180 def _graphic_preview_label(node: ShapeNode, label: str) -> str:
1181 return (
1182 f'<rect x="{fmt_num(node.xfrm.x)}" y="{fmt_num(node.xfrm.y)}" '
1183 f'width="{fmt_num(node.xfrm.w)}" height="22" '
1184 f'fill="#FFFFFF" fill-opacity="0.82" stroke="#999999" stroke-width="0.5"/>'
1185 f'<text x="{fmt_num(node.xfrm.x + 6)}" y="{fmt_num(node.xfrm.y + 15)}" '
1186 f'font-size="11" fill="#666666">[{_xml_escape(label)}]</text>'
1187 )
1188
1189
1190 def _replacement_payload_metadata(payload: object) -> str:
1191 payload_json = json.dumps(
1192 payload,
1193 allow_nan=False,
1194 ensure_ascii=False,
1195 separators=(",", ":"),
1196 )
1197 return (
1198 '<metadata type="application/json">'
1199 f'{_xml_text_escape(payload_json)}</metadata>'
1200 )
1201
1202
1203 def _render_graphic_table(
1204 node: ShapeNode,
1205 ctx: AssemblyContext,
1206 graphic_data: ET.Element | None,
1207 ) -> tuple[str, list[str], str]:
1208 """Convert the <a:tbl> child of a graphicFrame to SVG plus metadata."""
1209 if graphic_data is None:
1210 return "", [], ""
1211 tbl = graphic_data.find("a:tbl", NS)
1212 if tbl is None:
1213 return "", [], ""
1214 table_styles_part = ctx.pkg.resolve_table_styles()
1215 result = convert_tbl(
1216 tbl, node.xfrm, ctx.palette,
1217 table_styles=(
1218 table_styles_part.xml if table_styles_part is not None else None
1219 ),
1220 theme_fonts=ctx.theme_fonts,
1221 slide_number=ctx.slide_number,
1222 id_prefix=f"tbl{ctx.shape_seq[0]}",
1223 grad_seq=ctx.grad_seq,
1224 marker_seq=ctx.marker_seq,
1225 )
1226 if result.defs:
1227 ctx.defs.extend(result.defs)
1228 replacement_attrs: list[str] = ['data-pptx-import-source="pptx"']
1229 payload_metadata = ""
1230 if result.native_payload:
1231 if node.name and not result.native_payload.get("name"):
1232 result.native_payload["name"] = node.name
1233 payload_metadata = _replacement_payload_metadata(result.native_payload)
1234 replacement_attrs.append('data-pptx-replace-with="table"')
1235 elif result.native_status:
1236 replacement_attrs.append(
1237 'data-pptx-replacement-status="'
1238 f'{_xml_escape(result.native_status)}"'
1239 )
1240 if result.effect_reason:
1241 effect_metadata = unsupported_effect_metadata(result.effect_reason)
1242 _diagnose_unsupported_effect(ctx, effect_metadata)
1243 replacement_attrs.extend(_metadata_group_attrs(effect_metadata))
1244 return result.svg, replacement_attrs, payload_metadata
1245
1246
1247 def _render_graphic_chart(
1248 node: ShapeNode,
1249 ctx: AssemblyContext,
1250 graphic_data: ET.Element | None,
1251 preview_svg: str,
1252 ) -> tuple[str, list[str], str]:
1253 """Return a chart fallback plus native Chart replacement metadata."""
1254 result = extract_native_chart_payload(
1255 graphic_data,
1256 node.xfrm,
1257 ctx.slide_part,
1258 ctx.pkg,
1259 ctx.palette,
1260 )
1261 replacement_attrs: list[str] = ['data-pptx-import-source="pptx"']
1262 payload_metadata = ""
1263 if result.native_payload:
1264 if node.name and not result.native_payload.get("name"):
1265 result.native_payload["name"] = node.name
1266 payload_metadata = _replacement_payload_metadata(result.native_payload)
1267 replacement_attrs.append('data-pptx-replace-with="chart"')
1268 elif result.native_status:
1269 replacement_attrs.append(
1270 'data-pptx-replacement-status="'
1271 f'{_xml_escape(result.native_status)}"'
1272 )
1273
1274 rendered = preview_svg
1275 if rendered:
1276 replacement_attrs.append('data-pptx-fallback-kind="source-preview"')
1277 elif result.normalized_svg:
1278 rendered = result.normalized_svg
1279 replacement_attrs.append('data-pptx-fallback-kind="normalized"')
1280 else:
1281 replacement_attrs.append('data-pptx-fallback-kind="placeholder"')
1282 return rendered, replacement_attrs, payload_metadata
1283
1284
1285 def _render_graphic_preview(node: ShapeNode, ctx: AssemblyContext) -> str:
1286 """Render a graphicFrame's baked fallback preview bitmap when present.
1287
1288 PowerPoint stores a static raster preview for many embedded graphics
1289 inside ``mc:AlternateContent``. The Fallback branch is normally a plain
1290 ``p:pic`` (sometimes nested), so any conformant viewer that can't speak
1291 the richer object paints the preview. We do the same for flat preview SVGs.
1292
1293 Falls back to '' when the deck has no Fallback pic (very old or
1294 third-party authoring tools sometimes omit it). Caller then emits the
1295 dashed placeholder.
1296 """
1297 ac = node.xml.find("a:graphic/a:graphicData/mc:AlternateContent", NS)
1298 if ac is None:
1299 return ""
1300 pic = ac.find("mc:Fallback//p:pic", NS)
1301 if pic is None:
1302 # Some authoring tools put the preview directly in mc:Choice.
1303 pic = ac.find("mc:Choice//p:pic", NS)
1304 if pic is None:
1305 return ""
1306
1307 # The inner pic carries its own absolute xfrm in this deck (and in every
1308 # well-formed PPTX I've seen — PowerPoint copies the graphicFrame xfrm
1309 # there during save). If it's missing, fall back to the graphicFrame's
1310 # xfrm so the preview at least lands somewhere visible.
1311 inner_xfrm = node.xfrm
1312 pic_xfrm_elem = pic.find("p:spPr/a:xfrm", NS)
1313 if pic_xfrm_elem is not None:
1314 from .emu_units import parse_xfrm
1315 parsed = parse_xfrm(pic_xfrm_elem)
1316 if parsed.w > 0 and parsed.h > 0:
1317 inner_xfrm = parsed
1318
1319 result = convert_picture(
1320 pic, inner_xfrm, ctx.slide_part, ctx.pkg,
1321 media_subdir=ctx.media_subdir,
1322 embed_inline=ctx.embed_images,
1323 asset_name_map=ctx.asset_name_map,
1324 strict=ctx.strict,
1325 )
1326 if not result.svg:
1327 return ""
1328 _diagnose_picture_result(ctx, result)
1329 ctx.media.update(result.media)
1330 return result.svg
1331
1332
1333 # ---------------------------------------------------------------------------
1334 # Background
1335 # ---------------------------------------------------------------------------
1336
1337 def _emit_background(slide: SlideRef, ctx: AssemblyContext,
1338 w: float, h: float) -> str:
1339 """Inspect <p:bg> on slide / layout / master in inheritance order."""
1340 for part in (slide.part, slide.layout, slide.master):
1341 if part is None:
1342 continue
1343 bg = get_background(part.xml)
1344 if bg is None:
1345 continue
1346 bg_pr = bg.find("p:bgPr", NS)
1347 bg_ref = bg.find("p:bgRef", NS)
1348 placeholder_hex = None
1349
1350 if bg_pr is None and bg_ref is not None:
1351 bg_pr = _theme_background_fill(slide, ctx, bg_ref)
1352 color_elem = find_color_elem(bg_ref)
1353 placeholder_hex, _ = resolve_color(color_elem, ctx.palette)
1354 if bg_pr is None:
1355 continue
1356
1357 bg_image = _emit_background_image(bg_pr, part, ctx, w, h)
1358 if bg_image:
1359 return bg_image
1360
1361 fill = resolve_fill(
1362 bg_pr, ctx.palette,
1363 id_prefix="bg", id_seq=ctx.grad_seq,
1364 placeholder_hex=placeholder_hex,
1365 )
1366 ctx.defs.extend(fill.defs)
1367 if not fill.attrs:
1368 return ""
1369 # Convert dict to attributes
1370 attrs_xml = _attrs_to_xml(fill.attrs)
1371 return (f'<rect x="0" y="0" width="{fmt_num(w)}" height="{fmt_num(h)}"'
1372 f"{attrs_xml}/>")
1373 return ""
1374
1375
1376 def _emit_part_background(slide: SlideRef, ctx: AssemblyContext,
1377 w: float, h: float) -> str:
1378 """Render the background declared on the part itself only.
1379
1380 Distinct from `_emit_background`, which walks the slide → layout →
1381 master inheritance chain. Used by the layered solo renderer so each
1382 standalone master / layout SVG carries only its own ``<p:bg>`` — the
1383 inheritance is rebuilt by consumers re-stacking the layers, and we'd
1384 rather output nothing than have master decoration leak into a layout
1385 file.
1386 """
1387 bg = get_background(slide.part.xml)
1388 if bg is None:
1389 return ""
1390 bg_pr = bg.find("p:bgPr", NS)
1391 bg_ref = bg.find("p:bgRef", NS)
1392 placeholder_hex = None
1393
1394 if bg_pr is None and bg_ref is not None:
1395 bg_pr = _theme_background_fill(slide, ctx, bg_ref)
1396 color_elem = find_color_elem(bg_ref)
1397 placeholder_hex, _ = resolve_color(color_elem, ctx.palette)
1398 if bg_pr is None:
1399 return ""
1400
1401 bg_image = _emit_background_image(bg_pr, slide.part, ctx, w, h)
1402 if bg_image:
1403 return bg_image
1404
1405 fill = resolve_fill(
1406 bg_pr, ctx.palette,
1407 id_prefix="bg", id_seq=ctx.grad_seq,
1408 placeholder_hex=placeholder_hex,
1409 )
1410 ctx.defs.extend(fill.defs)
1411 if not fill.attrs:
1412 return ""
1413 attrs_xml = _attrs_to_xml(fill.attrs)
1414 return (f'<rect x="0" y="0" width="{fmt_num(w)}" height="{fmt_num(h)}"'
1415 f"{attrs_xml}/>")
1416
1417
1418 def _emit_background_image(
1419 bg_pr: ET.Element,
1420 source_part: PartRef,
1421 ctx: AssemblyContext,
1422 w: float,
1423 h: float,
1424 ) -> str:
1425 """Render a slide/layout/master background image fill as a full-canvas image."""
1426 blip_fill = bg_pr.find("a:blipFill", NS)
1427 if blip_fill is None:
1428 return ""
1429
1430 result = convert_blip_fill(
1431 blip_fill,
1432 Xfrm(0.0, 0.0, w, h),
1433 source_part,
1434 ctx.pkg,
1435 media_subdir=ctx.media_subdir,
1436 embed_inline=ctx.embed_images,
1437 asset_name_map=ctx.asset_name_map,
1438 strict=ctx.strict,
1439 )
1440 _diagnose_picture_result(ctx, result)
1441 if result.media:
1442 ctx.media.update(result.media)
1443 return result.svg
1444
1445
1446 def _theme_background_fill(
1447 slide: SlideRef,
1448 ctx: AssemblyContext,
1449 bg_ref: ET.Element,
1450 ) -> ET.Element | None:
1451 """Resolve p:bgRef idx into the theme background fill style list."""
1452 idx_raw = bg_ref.attrib.get("idx")
1453 if not idx_raw:
1454 return None
1455 try:
1456 idx = int(idx_raw)
1457 except ValueError:
1458 return None
1459 # ECMA style matrix background fill references are 1001-based.
1460 bg_fill_index = idx - 1001
1461 if bg_fill_index < 0:
1462 return None
1463
1464 theme = ctx.pkg.resolve_theme(slide.master)
1465 if theme is None:
1466 return None
1467 fill_list = theme.xml.find(".//a:fmtScheme/a:bgFillStyleLst", NS)
1468 if fill_list is None:
1469 return None
1470 fills = [child for child in list(fill_list) if isinstance(child.tag, str)]
1471 if bg_fill_index >= len(fills):
1472 return None
1473 return fills[bg_fill_index]
1474
1475
1476 def _emit_inherited_shapes(slide: SlideRef, ctx: AssemblyContext) -> list[str]:
1477 parts: list[str] = []
1478 show_layout_shapes, show_master_shapes = inherited_shape_visibility(slide)
1479 inherited_parts = (
1480 ("master-", slide.master, show_master_shapes),
1481 ("layout-", slide.layout, show_layout_shapes),
1482 )
1483 for prefix, part, visible in inherited_parts:
1484 if part is None or not visible:
1485 continue
1486 original_part = ctx.slide_part
1487 original_prefix = ctx.group_id_prefix
1488 ctx.slide_part = part
1489 ctx.group_id_prefix = prefix
1490 try:
1491 for node in walk_sp_tree(part.xml):
1492 if _is_placeholder_node(node):
1493 continue
1494 chunk = _convert_node(node, ctx, top_level=True)
1495 if chunk:
1496 parts.append(chunk)
1497 finally:
1498 ctx.slide_part = original_part
1499 ctx.group_id_prefix = original_prefix
1500 return parts
1501
1502
1503 def _is_placeholder_node(node: ShapeNode) -> bool:
1504 if node.placeholder is not None:
1505 return True
1506 if node.kind == GROUP:
1507 return all(_is_placeholder_node(child) for child in node.children)
1508 return False
1509
1510
1511 def _convert_placeholder_guide(node: ShapeNode, ctx: AssemblyContext,
1512 *, top_level: bool) -> str:
1513 """Emit the source-authored appearance of one template placeholder."""
1514 return _convert_node(node, ctx, top_level=top_level)
1515
1516
1517 # ---------------------------------------------------------------------------
1518 # Wrap / utilities
1519 # ---------------------------------------------------------------------------
1520
1521 def _wrap_shape_group(
1522 inner: str,
1523 node: ShapeNode,
1524 ctx: AssemblyContext,
1525 *,
1526 top_level: bool,
1527 extra_attrs: list[str] | None = None,
1528 ) -> str:
1529 """Wrap a shape's body in a <g> that carries the transform (rotation /
1530 flip) and an id for animation anchoring."""
1531 if not inner.strip():
1532 return ""
1533
1534 transform = node.xfrm.to_svg_transform()
1535 ctx.shape_seq[0] += 1
1536 seq = ctx.shape_seq[0]
1537 sid = node.spid or str(seq)
1538 g_id = f"{ctx.group_id_prefix}shape-{sid}"
1539
1540 attrs: list[str] = [f'id="{g_id}"']
1541 attrs.extend(
1542 f'{key}="{_xml_escape(value)}"'
1543 for key, value in _object_metadata(
1544 node,
1545 ctx,
1546 fallback_shape_id=sid,
1547 ).items()
1548 )
1549 if node.name:
1550 attrs.append(f'data-name="{_xml_escape(node.name)}"')
1551 if node.placeholder is not None and node.placeholder.type:
1552 attrs.append(f'data-ph-type="{_xml_escape(node.placeholder.type)}"')
1553 if node.placeholder is not None and node.kind == SHAPE:
1554 sp_pr = node.xml.find("p:spPr", NS)
1555 if sp_pr is not None and any(
1556 sp_pr.find(path, NS) is not None
1557 for path in ("a:prstGeom", "a:custGeom")
1558 ):
1559 attrs.append('data-pptx-placeholder-local-geometry="true"')
1560 if extra_attrs:
1561 attrs.extend(extra_attrs)
1562 if any(
1563 attribute.split("=", 1)[0] == "data-pptx-replace-with"
1564 for attribute in extra_attrs
1565 ):
1566 fallback_hash = svg_native_fallback_markup_fingerprint(
1567 inner,
1568 root_transform=transform,
1569 external_markup="".join(ctx.defs),
1570 )
1571 attrs.append(
1572 f'{NATIVE_FALLBACK_SHA256_ATTR}="{fallback_hash}"'
1573 )
1574 if transform:
1575 attrs.append(f'transform="{transform}"')
1576 return f"<g {' '.join(attrs)}>\n{inner}\n</g>"
1577
1578
1579 def _attrs_to_xml(attrs: dict[str, str]) -> str:
1580 if not attrs:
1581 return ""
1582 return "".join(f' {key}="{_xml_escape(value)}"' for key, value in attrs.items())
1583
1584
1585 def _metadata_group_attrs(attrs: dict[str, str]) -> list[str]:
1586 """Serialize import metadata for a logical object wrapper."""
1587 return [
1588 f'{key}="{_xml_escape(value)}"'
1589 for key, value in attrs.items()
1590 ]
1591
1592
1593 def _diagnose_unsupported_effect(
1594 ctx: AssemblyContext,
1595 metadata: dict[str, str],
1596 ) -> None:
1597 """Copy an import-only blocking effect marker into the conversion report."""
1598 reason = metadata.get(EFFECT_REASON_ATTR)
1599 if reason is None:
1600 return
1601 ctx.diagnose(
1602 "effect-unsupported",
1603 reason,
1604 "retain the base object and record blocking effect metadata",
1605 )
1606
1607
1608 def _geometry_group_attrs(geom: GeomResult | None) -> list[str]:
1609 """Mirror native geometry semantics onto the logical shape container."""
1610 if geom is None:
1611 return []
1612 keys = (
1613 "data-pptx-prst",
1614 "data-pptx-geometry-kind",
1615 "data-pptx-geometry-sha256",
1616 "data-pptx-preview-sha256",
1617 "data-pptx-geometry-status",
1618 "data-pptx-geometry-reason",
1619 EFFECT_STATUS_ATTR,
1620 EFFECT_REASON_ATTR,
1621 )
1622 attrs: list[str] = []
1623 for key, value in geom.attrs.items():
1624 if key in keys or key.startswith("data-pptx-av-"):
1625 attrs.append(f'{key}="{_xml_escape(value)}"')
1626 return attrs
1627
1628
1629 def _object_metadata(
1630 node: ShapeNode,
1631 ctx: AssemblyContext,
1632 *,
1633 fallback_shape_id: str = "",
1634 ) -> dict[str, str]:
1635 """Describe the source object without coupling geometry to its SVG bounds."""
1636 object_kind = {
1637 SHAPE: "shape",
1638 PICTURE: "picture",
1639 CONNECTOR: "connector",
1640 GROUP: "group",
1641 GRAPHIC: "graphic-frame",
1642 }.get(node.kind, node.kind)
1643 shape_id = node.spid or fallback_shape_id
1644 frame = " ".join((
1645 fmt_num(node.xfrm.x, 8),
1646 fmt_num(node.xfrm.y, 8),
1647 fmt_num(node.xfrm.w, 8),
1648 fmt_num(node.xfrm.h, 8),
1649 ))
1650 attrs = {
1651 "data-pptx-object": object_kind,
1652 "data-pptx-shape-id": shape_id,
1653 "data-pptx-shape-scope": _shape_scope(ctx),
1654 "data-pptx-frame": frame,
1655 }
1656 if node.name:
1657 attrs["data-pptx-shape-name"] = node.name
1658 if node.kind == CONNECTOR:
1659 attrs.update(_connector_metadata(node, _shape_scope(ctx)))
1660 return attrs
1661
1662
1663 def _shape_scope(ctx: AssemblyContext) -> str:
1664 if ctx.group_id_prefix.startswith("master-"):
1665 return "master"
1666 if ctx.group_id_prefix.startswith("layout-"):
1667 return "layout"
1668 return "slide"
1669
1670
1671 def _connector_metadata(node: ShapeNode, scope: str) -> dict[str, str]:
1672 """Preserve connector endpoint references when PowerPoint declares them."""
1673 attrs: dict[str, str] = {}
1674 cnv = node.xml.find("p:nvCxnSpPr/p:cNvCxnSpPr", NS)
1675 if cnv is None:
1676 return attrs
1677
1678 for endpoint, prefix in (("stCxn", "start"), ("endCxn", "end")):
1679 connection = cnv.find(f"a:{endpoint}", NS)
1680 if connection is None:
1681 continue
1682 shape_id = connection.attrib.get("id")
1683 site = connection.attrib.get("idx")
1684 if shape_id is not None:
1685 attrs[f"data-pptx-{prefix}-shape-id"] = shape_id
1686 attrs[f"data-pptx-{prefix}-shape-scope"] = scope
1687 if site is not None:
1688 attrs[f"data-pptx-{prefix}-site"] = site
1689 return attrs
1690
1691
1692 def _xml_escape(text: str) -> str:
1693 return _xml_text_escape(text).replace('"', "&quot;")
1694
1695
1696 def _xml_text_escape(text: str) -> str:
1697 return (text.replace("&", "&amp;")
1698 .replace("<", "&lt;")
1699 .replace(">", "&gt;"))
1700
1700 lines PYTHON