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