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