返回 ppt-master
shape_walker.py
根目录 / skills / ppt-master / scripts / pptx_to_svg / shape_walker.py
1 """Shape tree walker.
2
3 Reads <p:spTree> from a slide / layout / master and emits a normalized
4 ShapeNode tree that downstream converters can dispatch on.
5
6 Handles:
7 - <p:sp> -> SHAPE
8 - <p:pic> -> PICTURE
9 - <p:cxnSp> -> CONNECTOR
10 - <p:grpSp> -> GROUP (recurses; resolves a:chOff/a:chExt frame)
11 - <p:graphicFrame> -> GRAPHIC (table / chart / SmartArt — emitted as opaque
12 placeholder for v1 so callers can decide a fallback)
13 - <mc:AlternateContent> -> supported Choice shape with the baked Fallback
14 preview retained for graphic frames
15 """
16
17 from __future__ import annotations
18
19 from dataclasses import dataclass, field
20 from xml.etree import ElementTree as ET
21
22 from .emu_units import NS, Xfrm, ooxml_bool, parse_xfrm
23
24
25 # ---------------------------------------------------------------------------
26 # ShapeNode
27 # ---------------------------------------------------------------------------
28
29 SHAPE = "sp"
30 PICTURE = "pic"
31 CONNECTOR = "cxnSp"
32 GROUP = "grpSp"
33 GRAPHIC = "graphicFrame"
34
35 _TITLE_PLACEHOLDER_TYPES = {"title", "ctrTitle"}
36 _BODY_PLACEHOLDER_TYPES = {
37 "body",
38 "chart",
39 "clipArt",
40 "dgm",
41 "media",
42 "obj",
43 "pic",
44 "subTitle",
45 "tbl",
46 }
47 _DEFAULT_PLACEHOLDER_TYPE = "obj"
48 _DEFAULT_PLACEHOLDER_INDEX = "0"
49 _TX_STYLE_TITLE_KEY = ("__txStyleTitle", None)
50 _TX_STYLE_BODY_KEY = ("__txStyleBody", None)
51 _TX_STYLE_OTHER_KEY = ("__txStyleOther", None)
52
53
54 @dataclass
55 class PlaceholderInfo:
56 """Resolved <p:ph> attributes for a shape if any."""
57
58 type: str | None = None # title / body / ctrTitle / subTitle / ftr / dt / ...
59 idx: str | None = None
60 sz: str | None = None # full / half / quarter
61 orient: str | None = None
62
63
64 @dataclass
65 class ShapeNode:
66 """Normalized shape entry produced by the walker."""
67
68 kind: str # one of SHAPE / PICTURE / CONNECTOR / GROUP / GRAPHIC
69 xml: ET.Element # original element
70 xfrm: Xfrm # resolved geometry in absolute slide pixel space
71 name: str = ""
72 spid: str = ""
73 hidden: bool = False
74 placeholder: PlaceholderInfo | None = None
75 inherited_lst_styles: tuple[ET.Element, ...] = ()
76 inherited_body_properties: tuple[ET.Element, ...] = ()
77 # Local plus ancestor group rotation; used for effect-fidelity decisions
78 # without applying the group transform twice to the rendered geometry.
79 effective_rotation: float = 0.0
80 # GROUP only: children, in z-order
81 children: list["ShapeNode"] = field(default_factory=list)
82
83
84 # ---------------------------------------------------------------------------
85 # Walker
86 # ---------------------------------------------------------------------------
87
88 def _read_nv_sp_pr(parent: ET.Element, nv_tag: str) -> tuple[str, str, bool, PlaceholderInfo | None]:
89 """Extract name/id/hidden/placeholder from an nvXXXPr container.
90
91 nv_tag is one of nvSpPr / nvPicPr / nvCxnSpPr / nvGrpSpPr / nvGraphicFramePr.
92 """
93 container = parent.find(f"p:{nv_tag}", NS)
94 name = ""
95 spid = ""
96 hidden = False
97 ph: PlaceholderInfo | None = None
98 if container is None:
99 return name, spid, hidden, ph
100
101 cnv = container.find("p:cNvPr", NS)
102 if cnv is not None:
103 name = cnv.attrib.get("name", "")
104 spid = cnv.attrib.get("id", "")
105 if ooxml_bool(cnv.attrib.get("hidden")):
106 hidden = True
107
108 nv_pr = container.find("p:nvPr", NS)
109 if nv_pr is not None:
110 ph_elem = nv_pr.find("p:ph", NS)
111 if ph_elem is not None:
112 ph = PlaceholderInfo(
113 type=ph_elem.attrib.get("type"),
114 idx=ph_elem.attrib.get("idx"),
115 sz=ph_elem.attrib.get("sz"),
116 orient=ph_elem.attrib.get("orient"),
117 )
118
119 return name, spid, hidden, ph
120
121
122 def _resolve_xfrm(shape: ET.Element, kind: str) -> ET.Element | None:
123 """Find the <a:xfrm> element under the right spPr / grpSpPr container."""
124 if kind == GROUP:
125 sp_pr = shape.find("p:grpSpPr", NS)
126 elif kind == GRAPHIC:
127 # graphicFrame uses p:xfrm directly (no a: namespace)
128 return shape.find("p:xfrm", NS)
129 else:
130 sp_pr = shape.find("p:spPr", NS)
131 if sp_pr is None:
132 return None
133 return sp_pr.find("a:xfrm", NS)
134
135
136 def _adjust_for_group(child_xfrm: Xfrm, group_xfrm: Xfrm) -> Xfrm:
137 """Map a child shape's xfrm from group's child coordinate space into the
138 parent (group) coordinate space.
139
140 DrawingML group rule: a child's a:off/a:ext is in the group's chOff/chExt
141 coordinate system. We map to the group's actual off/ext on the slide.
142
143 If the group has no chOff/chExt, fall back to identity translation.
144 """
145 if (group_xfrm.ch_w is None or group_xfrm.ch_h is None
146 or group_xfrm.ch_w == 0 or group_xfrm.ch_h == 0):
147 # No child frame — children already in slide space; just translate.
148 return child_xfrm
149
150 # Linear map: child-frame -> group's (off..off+ext)
151 sx = group_xfrm.w / group_xfrm.ch_w if group_xfrm.ch_w else 1.0
152 sy = group_xfrm.h / group_xfrm.ch_h if group_xfrm.ch_h else 1.0
153 ch_x = group_xfrm.ch_x or 0.0
154 ch_y = group_xfrm.ch_y or 0.0
155
156 new_x = group_xfrm.x + (child_xfrm.x - ch_x) * sx
157 new_y = group_xfrm.y + (child_xfrm.y - ch_y) * sy
158 new_w = child_xfrm.w * sx
159 new_h = child_xfrm.h * sy
160
161 return Xfrm(
162 x=new_x, y=new_y, w=new_w, h=new_h,
163 rot=child_xfrm.rot,
164 flip_h=child_xfrm.flip_h,
165 flip_v=child_xfrm.flip_v,
166 ch_x=child_xfrm.ch_x, ch_y=child_xfrm.ch_y,
167 ch_w=child_xfrm.ch_w, ch_h=child_xfrm.ch_h,
168 )
169
170
171 # Mapping from element tag -> kind / nv tag.
172 _KIND_MAP = {
173 "sp": (SHAPE, "nvSpPr"),
174 "pic": (PICTURE, "nvPicPr"),
175 "cxnSp": (CONNECTOR, "nvCxnSpPr"),
176 "grpSp": (GROUP, "nvGrpSpPr"),
177 "graphicFrame": (GRAPHIC, "nvGraphicFramePr"),
178 }
179
180
181 def _first_shape_child(container: ET.Element | None) -> ET.Element | None:
182 if container is None:
183 return None
184 for child in list(container):
185 if not isinstance(child.tag, str):
186 continue
187 if child.tag.split("}", 1)[-1] in _KIND_MAP:
188 return child
189 return None
190
191
192 def _resolve_alternate_content(wrapper: ET.Element) -> ET.Element | None:
193 """Select an AlternateContent shape while retaining its baked preview."""
194 choice = wrapper.find("mc:Choice", NS)
195 fallback = wrapper.find("mc:Fallback", NS)
196 selected = _first_shape_child(choice)
197 selected_from_choice = selected is not None
198 if selected is None:
199 selected = _first_shape_child(fallback)
200 if selected is None:
201 return None
202
203 clone = ET.fromstring(ET.tostring(selected, encoding="utf-8"))
204 if (
205 selected_from_choice
206 and clone.tag.split("}", 1)[-1] == "graphicFrame"
207 and fallback is not None
208 ):
209 graphic_data = clone.find("a:graphic/a:graphicData", NS)
210 if graphic_data is not None:
211 preview = ET.Element(f"{{{NS['mc']}}}AlternateContent")
212 preview.append(
213 ET.fromstring(ET.tostring(fallback, encoding="utf-8"))
214 )
215 graphic_data.append(preview)
216 return clone
217
218
219 def _walk_container(
220 container: ET.Element,
221 parent_group_xfrm: Xfrm | None,
222 ancestor_rotation: float = 0.0,
223 placeholder_xfrms: dict[tuple[str | None, str | None], Xfrm] | None = None,
224 placeholder_lst_styles: dict[
225 tuple[str | None, str | None],
226 list[ET.Element],
227 ] | None = None,
228 placeholder_body_properties: dict[
229 tuple[str | None, str | None],
230 list[ET.Element],
231 ] | None = None,
232 ) -> list[ShapeNode]:
233 """Walk a p:spTree or p:grpSp subtree. Children kept in document (z) order.
234 """
235 nodes: list[ShapeNode] = []
236 for child in list(container):
237 if not isinstance(child.tag, str):
238 continue
239 local = child.tag.split("}", 1)[-1]
240 if local == "AlternateContent":
241 resolved = _resolve_alternate_content(child)
242 if resolved is None:
243 continue
244 child = resolved
245 local = child.tag.split("}", 1)[-1]
246 kind_info = _KIND_MAP.get(local)
247 if kind_info is None:
248 continue
249 kind, nv_tag = kind_info
250
251 name, spid, hidden, ph = _read_nv_sp_pr(child, nv_tag)
252 xfrm = parse_xfrm(_resolve_xfrm(child, kind))
253 effective_rotation = (ancestor_rotation + xfrm.rot) % 360.0
254
255 # Placeholders without their own xfrm inherit geometry from a matching
256 # placeholder in the layout, then the master. This is what PowerPoint
257 # itself does when rendering the slide. Without this fallback such
258 # shapes get a 0×0 box and convert_txbody wraps every glyph onto its
259 # own line — visually a vertical strip of single characters.
260 if (ph is not None and placeholder_xfrms
261 and (xfrm.w == 0 and xfrm.h == 0)):
262 inherited = _lookup_placeholder_xfrm(ph, placeholder_xfrms)
263 if inherited is not None:
264 xfrm = Xfrm(
265 x=inherited.x, y=inherited.y,
266 w=inherited.w, h=inherited.h,
267 rot=xfrm.rot, flip_h=xfrm.flip_h, flip_v=xfrm.flip_v,
268 ch_x=xfrm.ch_x, ch_y=xfrm.ch_y,
269 ch_w=xfrm.ch_w, ch_h=xfrm.ch_h,
270 )
271
272 # If we're inside a group, remap to slide-absolute coordinates
273 if parent_group_xfrm is not None:
274 xfrm = _adjust_for_group(xfrm, parent_group_xfrm)
275
276 inherited_lst_styles: tuple[ET.Element, ...] = ()
277 if ph is not None and placeholder_lst_styles:
278 inherited_lst_styles = _lookup_placeholder_lst_styles(
279 ph, placeholder_lst_styles,
280 )
281 inherited_body_properties: tuple[ET.Element, ...] = ()
282 if ph is not None and placeholder_body_properties:
283 inherited_body_properties = _lookup_placeholder_body_properties(
284 ph,
285 placeholder_body_properties,
286 )
287
288 node = ShapeNode(
289 kind=kind, xml=child, xfrm=xfrm,
290 name=name, spid=spid, hidden=hidden, placeholder=ph,
291 inherited_lst_styles=inherited_lst_styles,
292 inherited_body_properties=inherited_body_properties,
293 effective_rotation=effective_rotation,
294 )
295
296 if kind == GROUP:
297 node.children = _walk_container(
298 child, xfrm, effective_rotation,
299 placeholder_xfrms=placeholder_xfrms,
300 placeholder_lst_styles=placeholder_lst_styles,
301 placeholder_body_properties=placeholder_body_properties,
302 )
303
304 nodes.append(node)
305 return nodes
306
307
308 def _lookup_placeholder_xfrm(
309 ph: PlaceholderInfo,
310 table: dict[tuple[str | None, str | None], Xfrm],
311 ) -> Xfrm | None:
312 """Find inherited geometry after applying the OOXML placeholder defaults."""
313 ph_type, ph_idx = _placeholder_identity(ph.type, ph.idx)
314 for key in (
315 (ph_type, ph_idx),
316 (ph_type, None),
317 (None, ph_idx),
318 ):
319 hit = table.get(key)
320 if hit is not None and (hit.w > 0 or hit.h > 0):
321 return hit
322 return None
323
324
325 def _lookup_placeholder_lst_styles(
326 ph: PlaceholderInfo,
327 table: dict[tuple[str | None, str | None], list[ET.Element]],
328 ) -> tuple[ET.Element, ...]:
329 """Find inherited txBody/lstStyle elements for a placeholder."""
330 ph_type, ph_idx = _placeholder_identity(ph.type, ph.idx)
331 styles: list[ET.Element] = []
332 seen: set[int] = set()
333 for key in (
334 (ph_type, ph_idx),
335 (ph_type, None),
336 (None, ph_idx),
337 _placeholder_tx_style_key(ph),
338 ):
339 for style in table.get(key, []):
340 marker = id(style)
341 if marker in seen:
342 continue
343 styles.append(style)
344 seen.add(marker)
345 return tuple(styles)
346
347
348 def _lookup_placeholder_body_properties(
349 ph: PlaceholderInfo,
350 table: dict[tuple[str | None, str | None], list[ET.Element]],
351 ) -> tuple[ET.Element, ...]:
352 """Find inherited txBody/bodyPr elements for a placeholder."""
353 ph_type, ph_idx = _placeholder_identity(ph.type, ph.idx)
354 exact = table.get((ph_type, ph_idx), [])
355 if exact:
356 return tuple(exact)
357 for key in ((ph_type, None), (None, ph_idx)):
358 candidates = table.get(key, [])
359 if candidates:
360 return (candidates[0],)
361 return ()
362
363
364 def _placeholder_tx_style_key(
365 ph: PlaceholderInfo,
366 ) -> tuple[str | None, str | None]:
367 ph_type, _ph_idx = _placeholder_identity(ph.type, ph.idx)
368 if ph_type in _TITLE_PLACEHOLDER_TYPES:
369 return _TX_STYLE_TITLE_KEY
370 if ph_type in _BODY_PLACEHOLDER_TYPES:
371 return _TX_STYLE_BODY_KEY
372 return _TX_STYLE_OTHER_KEY
373
374
375 def _placeholder_identity(
376 ph_type: str | None,
377 ph_idx: str | None,
378 ) -> tuple[str, str]:
379 """Resolve the schema defaults used for placeholder inheritance keys."""
380 return (
381 _DEFAULT_PLACEHOLDER_TYPE if ph_type is None else ph_type,
382 _DEFAULT_PLACEHOLDER_INDEX if ph_idx is None else ph_idx,
383 )
384
385
386 def _build_placeholder_xfrm_table(
387 *parts: ET.Element | None,
388 ) -> dict[tuple[str | None, str | None], Xfrm]:
389 """Index placeholders that *do* have explicit geometry, in priority order.
390
391 Pass parts most-specific to least-specific (layout first, master second);
392 the first writer for a given key wins so layout overrides master, which is
393 what PowerPoint's inheritance chain expects.
394 """
395 table: dict[tuple[str | None, str | None], Xfrm] = {}
396 for part_xml in parts:
397 if part_xml is None:
398 continue
399 sp_tree = part_xml.find("p:cSld/p:spTree", NS)
400 if sp_tree is None:
401 continue
402 for sp in sp_tree.iter():
403 if not isinstance(sp.tag, str) or sp.tag.split("}", 1)[-1] != "sp":
404 continue
405 ph_elem = sp.find("p:nvSpPr/p:nvPr/p:ph", NS)
406 if ph_elem is None:
407 continue
408 xfrm_elem = sp.find("p:spPr/a:xfrm", NS)
409 if xfrm_elem is None:
410 continue
411 xfrm = parse_xfrm(xfrm_elem)
412 if xfrm.w <= 0 and xfrm.h <= 0:
413 continue
414 ph_type, ph_idx = _placeholder_identity(
415 ph_elem.attrib.get("type"),
416 ph_elem.attrib.get("idx"),
417 )
418 for key in ((ph_type, ph_idx),
419 (ph_type, None),
420 (None, ph_idx)):
421 table.setdefault(key, xfrm)
422 return table
423
424
425 def _build_placeholder_lst_style_table(
426 *parts: ET.Element | None,
427 ) -> dict[tuple[str | None, str | None], list[ET.Element]]:
428 """Index placeholder txBody/lstStyle elements in priority order."""
429 table: dict[tuple[str | None, str | None], list[ET.Element]] = {}
430 for part_xml in parts:
431 if part_xml is None:
432 continue
433 sp_tree = part_xml.find("p:cSld/p:spTree", NS)
434 if sp_tree is None:
435 continue
436 for sp in sp_tree.iter():
437 if not isinstance(sp.tag, str) or sp.tag.split("}", 1)[-1] != "sp":
438 continue
439 ph_elem = sp.find("p:nvSpPr/p:nvPr/p:ph", NS)
440 if ph_elem is None:
441 continue
442 lst_style = sp.find("p:txBody/a:lstStyle", NS)
443 if lst_style is None:
444 continue
445 ph_type, ph_idx = _placeholder_identity(
446 ph_elem.attrib.get("type"),
447 ph_elem.attrib.get("idx"),
448 )
449 for key in ((ph_type, ph_idx),
450 (ph_type, None),
451 (None, ph_idx)):
452 table.setdefault(key, []).append(lst_style)
453 _append_master_tx_styles(table, part_xml)
454 return table
455
456
457 def _build_placeholder_body_property_table(
458 *parts: ET.Element | None,
459 ) -> dict[tuple[str | None, str | None], list[ET.Element]]:
460 """Index placeholder txBody/bodyPr elements in priority order."""
461 table: dict[tuple[str | None, str | None], list[ET.Element]] = {}
462 for part_xml in parts:
463 if part_xml is None:
464 continue
465 sp_tree = part_xml.find("p:cSld/p:spTree", NS)
466 if sp_tree is None:
467 continue
468 for sp in sp_tree.iter():
469 if not isinstance(sp.tag, str) or sp.tag.split("}", 1)[-1] != "sp":
470 continue
471 ph_elem = sp.find("p:nvSpPr/p:nvPr/p:ph", NS)
472 body_pr = sp.find("p:txBody/a:bodyPr", NS)
473 if ph_elem is None or body_pr is None:
474 continue
475 ph_type, ph_idx = _placeholder_identity(
476 ph_elem.attrib.get("type"),
477 ph_elem.attrib.get("idx"),
478 )
479 for key in (
480 (ph_type, ph_idx),
481 (ph_type, None),
482 (None, ph_idx),
483 ):
484 table.setdefault(key, []).append(body_pr)
485 return table
486
487
488 def _append_master_tx_styles(
489 table: dict[tuple[str | None, str | None], list[ET.Element]],
490 part_xml: ET.Element,
491 ) -> None:
492 for key, path in (
493 (_TX_STYLE_TITLE_KEY, "p:txStyles/p:titleStyle"),
494 (_TX_STYLE_BODY_KEY, "p:txStyles/p:bodyStyle"),
495 (_TX_STYLE_OTHER_KEY, "p:txStyles/p:otherStyle"),
496 ):
497 style = part_xml.find(path, NS)
498 if style is not None:
499 table.setdefault(key, []).append(style)
500
501
502 def walk_sp_tree(
503 slide_xml: ET.Element,
504 *,
505 layout_xml: ET.Element | None = None,
506 master_xml: ET.Element | None = None,
507 ) -> list[ShapeNode]:
508 """Top-level entry: return shape nodes for a slide / layout / master XML.
509
510 When ``slide_xml`` is a regular slide, pass its ``layout_xml`` and
511 ``master_xml`` so placeholders can inherit geometry, text list styles, and
512 body properties from the layout/master. Layout and master walks pass
513 neither — their own placeholders are the source of truth.
514 """
515 sp_tree = slide_xml.find("p:cSld/p:spTree", NS)
516 if sp_tree is None:
517 return []
518 placeholder_xfrms = _build_placeholder_xfrm_table(layout_xml, master_xml)
519 placeholder_lst_styles = _build_placeholder_lst_style_table(
520 layout_xml, master_xml,
521 )
522 placeholder_body_properties = _build_placeholder_body_property_table(
523 layout_xml,
524 master_xml,
525 )
526 return _walk_container(
527 sp_tree, parent_group_xfrm=None,
528 placeholder_xfrms=placeholder_xfrms or None,
529 placeholder_lst_styles=placeholder_lst_styles or None,
530 placeholder_body_properties=placeholder_body_properties or None,
531 )
532
533
534 def get_background(slide_xml: ET.Element) -> ET.Element | None:
535 """Return the <p:bg> element if the slide defines its own background."""
536 return slide_xml.find("p:cSld/p:bg", NS)
537
537 lines PYTHON