返回 ppt-master
ooxml_loader.py
根目录 / skills / ppt-master / scripts / pptx_to_svg / ooxml_loader.py
1 """OOXML loader: read .pptx zip package, resolve relationships, expose
2 slide / layout / master / theme / media as a navigable structure.
3
4 Mirrors what manifest.py does for analysis, but keeps the parsed XML roots
5 accessible for downstream shape conversion.
6 """
7
8 from __future__ import annotations
9
10 import posixpath
11 import re
12 import zipfile
13 from dataclasses import dataclass, field
14 from pathlib import Path, PurePosixPath
15 from typing import Iterator
16 from xml.etree import ElementTree as ET
17
18 from .emu_units import NS, emu_attr_to_px
19
20
21 # ---------------------------------------------------------------------------
22 # Relationship parsing
23 # ---------------------------------------------------------------------------
24
25 REL_NS = NS["rel"]
26 PACKAGE_REL = "_rels/.rels"
27 PRESENTATION_REL_PREFIX = "ppt/_rels/presentation.xml.rels"
28
29
30 def _normalize_part_path(target: str, base: str | None = None) -> str:
31 """Resolve a relationship target against its rels source.
32
33 PPTX rels typically use paths relative to the rels file's parent directory,
34 e.g. slide1.xml.rels says target="../theme/theme1.xml". The result is
35 normalized to a posix path with no leading slash and no '..'.
36 """
37 target = target.replace("\\", "/")
38 if target.startswith("/"):
39 return target.lstrip("/")
40 if base is None:
41 return target
42 base_dir = posixpath.dirname(base.rstrip("/"))
43 joined = posixpath.normpath(posixpath.join(base_dir, target))
44 return joined.lstrip("/")
45
46
47 def _rels_path_for(part_path: str) -> str:
48 """Given a part path 'ppt/slides/slide1.xml', return its rels path."""
49 parent, name = posixpath.split(part_path)
50 if not parent:
51 return f"_rels/{name}.rels"
52 return f"{parent}/_rels/{name}.rels"
53
54
55 def _parse_rels(zf: zipfile.ZipFile, rels_path: str) -> dict[str, dict[str, str]]:
56 """Parse a .rels file. Returns {rId: {'type': ..., 'target': absolute_part_path}}."""
57 if rels_path not in zf.namelist():
58 return {}
59 try:
60 root = ET.fromstring(zf.read(rels_path))
61 except ET.ParseError as exc:
62 raise RuntimeError(f"Invalid relationships XML in {rels_path}: {exc}") from exc
63
64 base = rels_path.replace("/_rels/", "/") # source part path
65 base = base[:-len(".rels")] # strip .rels suffix
66 rels: dict[str, dict[str, str]] = {}
67 for child in root.findall(f"{{{REL_NS}}}Relationship"):
68 rid = child.attrib.get("Id", "")
69 rtype = child.attrib.get("Type", "")
70 target = child.attrib.get("Target", "")
71 target_mode = child.attrib.get("TargetMode", "")
72 # External relationships (e.g. hyperlinks) keep the raw target.
73 if target_mode == "External":
74 rels[rid] = {"type": rtype, "target": target, "external": "1"}
75 continue
76 absolute = _normalize_part_path(target, base)
77 rels[rid] = {"type": rtype, "target": absolute}
78 return rels
79
80
81 def _load_xml(zf: zipfile.ZipFile, part_path: str) -> ET.Element | None:
82 if part_path not in zf.namelist():
83 return None
84 try:
85 return ET.fromstring(zf.read(part_path))
86 except ET.ParseError as exc:
87 raise RuntimeError(f"Invalid OOXML part {part_path}: {exc}") from exc
88
89
90 def blip_embed_relationship_ids(blip: ET.Element) -> tuple[str, ...]:
91 """Return embedded image relationships in fidelity-preferred order.
92
93 Modern Office stores an editable SVG relationship in ``asvg:svgBlip``
94 while keeping a raster fallback on the owning ``a:blip``. Consumers must
95 try the SVG relationship first and retain the raster relationship only as
96 a compatibility fallback.
97 """
98 embed_attr = f"{{{NS['r']}}}embed"
99 candidates = [
100 node.attrib.get(embed_attr)
101 for node in blip.findall(".//asvg:svgBlip", NS)
102 ]
103 candidates.append(blip.attrib.get(embed_attr))
104
105 ordered: list[str] = []
106 for rel_id in candidates:
107 if rel_id and rel_id not in ordered:
108 ordered.append(rel_id)
109 return tuple(ordered)
110
111
112 # ---------------------------------------------------------------------------
113 # Data classes for navigable parts
114 # ---------------------------------------------------------------------------
115
116 @dataclass
117 class PartRef:
118 """A loaded XML part with its rels resolved."""
119
120 path: str
121 xml: ET.Element
122 rels: dict[str, dict[str, str]] = field(default_factory=dict)
123
124 def resolve_rel(self, rid: str) -> str | None:
125 """Resolve an rId to an absolute part path. Returns None if missing."""
126 info = self.rels.get(rid)
127 if info is None:
128 return None
129 if info.get("external"):
130 return None
131 return info.get("target")
132
133
134 @dataclass
135 class SlideRef:
136 """One slide, with its layout / master chain attached."""
137
138 index: int # 1-based
139 part: PartRef
140 layout: PartRef | None
141 master: PartRef | None
142
143
144 def parse_ooxml_boolean(
145 raw: str | None,
146 *,
147 default: bool,
148 context: str,
149 ) -> bool:
150 """Parse one XML Schema boolean without silently accepting bad OOXML."""
151 if raw is None:
152 return default
153 token = raw.strip()
154 if token in {"1", "true"}:
155 return True
156 if token in {"0", "false"}:
157 return False
158 raise RuntimeError(f"{context}: invalid boolean value {raw!r}")
159
160
161 def part_show_master_sp(part: PartRef) -> bool:
162 """Return one slide/layout part's raw ``showMasterSp`` semantic value."""
163 return parse_ooxml_boolean(
164 part.xml.attrib.get("showMasterSp"),
165 default=True,
166 context=f"{part.path} showMasterSp",
167 )
168
169
170 def inherited_shape_visibility(slide: SlideRef) -> tuple[bool, bool]:
171 """Return effective ``(layout_shapes, master_shapes)`` visibility.
172
173 A slide-level false value suppresses both inherited shape trees. A
174 layout-level false value suppresses only its parent Master's shape tree.
175 Background inheritance is separate and intentionally not represented by
176 these booleans.
177 """
178 show_layout_shapes = part_show_master_sp(slide.part)
179 layout_shows_master_shapes = (
180 part_show_master_sp(slide.layout)
181 if slide.layout is not None else True
182 )
183 show_master_shapes = (
184 show_layout_shapes
185 and slide.master is not None
186 and layout_shows_master_shapes
187 )
188 return show_layout_shapes, show_master_shapes
189
190
191 # ---------------------------------------------------------------------------
192 # OoxmlPackage
193 # ---------------------------------------------------------------------------
194
195 # Relationship type constants
196 REL_TYPES = {
197 "presentation": "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument",
198 "slide": "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide",
199 "slideLayout": "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout",
200 "slideMaster": "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster",
201 "theme": "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme",
202 "tableStyles": "http://schemas.openxmlformats.org/officeDocument/2006/relationships/tableStyles",
203 "image": "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image",
204 "media": "http://schemas.openxmlformats.org/officeDocument/2006/relationships/media",
205 }
206
207
208 class OoxmlPackage:
209 """Loaded .pptx ready for shape walking.
210
211 Usage:
212 with OoxmlPackage(Path("foo.pptx")) as pkg:
213 for slide in pkg.iter_slides():
214 root = slide.part.xml
215 ...
216 """
217
218 def __init__(self, pptx_path: Path) -> None:
219 self.path = pptx_path
220 self.zip: zipfile.ZipFile | None = None
221 self.presentation: PartRef | None = None
222 self.slide_size_px: tuple[float, float] = (1280.0, 720.0)
223 self.slide_size_emu: tuple[int, int] = (12192000, 6858000)
224 self.first_slide_number: int = 1
225 self._slides: list[SlideRef] = []
226 self._layouts: dict[str, PartRef] = {}
227 self._masters: dict[str, PartRef] = {}
228 self._themes: dict[str, PartRef] = {}
229 self._table_styles: PartRef | None = None
230 self._table_styles_loaded = False
231
232 # ------------------- context manager -------------------
233
234 def __enter__(self) -> "OoxmlPackage":
235 self.open()
236 return self
237
238 def __exit__(self, exc_type, exc, tb) -> None:
239 self.close()
240
241 def open(self) -> None:
242 if self.zip is not None:
243 return
244 self.zip = zipfile.ZipFile(self.path, "r")
245 self._load_presentation()
246 self._load_slides()
247
248 def close(self) -> None:
249 if self.zip is not None:
250 self.zip.close()
251 self.zip = None
252
253 # ------------------- low-level helpers -------------------
254
255 def _load_part(self, part_path: str) -> PartRef | None:
256 assert self.zip is not None
257 xml = _load_xml(self.zip, part_path)
258 if xml is None:
259 return None
260 rels = _parse_rels(self.zip, _rels_path_for(part_path))
261 return PartRef(path=part_path, xml=xml, rels=rels)
262
263 def load_part(self, part_path: str) -> PartRef | None:
264 """Load an arbitrary XML part by package path."""
265 return self._load_part(part_path)
266
267 def read_media(self, part_path: str) -> bytes | None:
268 """Return raw bytes of an embedded media part (e.g. ppt/media/image1.png)."""
269 assert self.zip is not None
270 if part_path not in self.zip.namelist():
271 return None
272 return self.zip.read(part_path)
273
274 def media_filename(self, part_path: str) -> str:
275 """Last segment of the media path, e.g. 'image1.png'."""
276 return PurePosixPath(part_path).name
277
278 # ------------------- loading sequence -------------------
279
280 def _load_presentation(self) -> None:
281 assert self.zip is not None
282 # /_rels/.rels -> presentation.xml
283 package_rels = _parse_rels(self.zip, PACKAGE_REL)
284 pres_path = None
285 for info in package_rels.values():
286 if info.get("type") == REL_TYPES["presentation"]:
287 pres_path = info["target"]
288 break
289 if pres_path is None:
290 pres_path = "ppt/presentation.xml"
291
292 self.presentation = self._load_part(pres_path)
293 if self.presentation is None:
294 raise RuntimeError(f"presentation.xml missing in {self.path}")
295
296 raw_first_slide_number = self.presentation.xml.attrib.get("firstSlideNum")
297 if raw_first_slide_number is not None:
298 first_slide_token = raw_first_slide_number.strip(" \t\r\n")
299 if re.fullmatch(r"[+-]?[0-9]+", first_slide_token) is None:
300 raise RuntimeError(
301 f"Invalid presentation firstSlideNum: {raw_first_slide_number!r}"
302 )
303 digits = first_slide_token.lstrip("+-").lstrip("0") or "0"
304 if len(digits) > 10:
305 raise RuntimeError("Presentation firstSlideNum is outside xsd:int")
306 first_slide_number = int(digits)
307 if first_slide_token.startswith("-"):
308 first_slide_number = -first_slide_number
309 if not -(2**31) <= first_slide_number <= 2**31 - 1:
310 raise RuntimeError(
311 f"Presentation firstSlideNum is outside xsd:int: {first_slide_number}"
312 )
313 self.first_slide_number = first_slide_number
314
315 # slide size
316 size = self.presentation.xml.find("p:sldSz", NS)
317 if size is not None:
318 cx = int(size.attrib.get("cx", "12192000"))
319 cy = int(size.attrib.get("cy", "6858000"))
320 self.slide_size_emu = (cx, cy)
321 self.slide_size_px = (cx / 9525.0, cy / 9525.0)
322
323 def _load_slides(self) -> None:
324 assert self.zip is not None and self.presentation is not None
325 # presentation.xml has <p:sldIdLst><p:sldId r:id="rId..."/></p:sldIdLst>
326 # in document order.
327 sld_id_lst = self.presentation.xml.find("p:sldIdLst", NS)
328 if sld_id_lst is None:
329 return
330
331 for index, sld_id in enumerate(sld_id_lst.findall("p:sldId", NS), start=1):
332 rid = sld_id.attrib.get(f"{{{NS['r']}}}id")
333 if not rid:
334 continue
335 slide_path = self.presentation.resolve_rel(rid)
336 if not slide_path:
337 continue
338 slide_part = self._load_part(slide_path)
339 if slide_part is None:
340 continue
341 layout = self._resolve_layout(slide_part)
342 master = self._resolve_master(layout) if layout else None
343 self._slides.append(SlideRef(
344 index=index, part=slide_part, layout=layout, master=master,
345 ))
346
347 def _resolve_layout(self, slide: PartRef) -> PartRef | None:
348 for info in slide.rels.values():
349 if info.get("type") == REL_TYPES["slideLayout"]:
350 target = info["target"]
351 cached = self._layouts.get(target)
352 if cached is None:
353 cached = self._load_part(target)
354 if cached is not None:
355 self._layouts[target] = cached
356 return cached
357 return None
358
359 def _resolve_master(self, layout: PartRef) -> PartRef | None:
360 for info in layout.rels.values():
361 if info.get("type") == REL_TYPES["slideMaster"]:
362 target = info["target"]
363 cached = self._masters.get(target)
364 if cached is None:
365 cached = self._load_part(target)
366 if cached is not None:
367 self._masters[target] = cached
368 return cached
369 return None
370
371 def resolve_theme(self, master: PartRef | None) -> PartRef | None:
372 """Return the theme part referenced by a slide master."""
373 if master is None:
374 return None
375 for info in master.rels.values():
376 if info.get("type") == REL_TYPES["theme"]:
377 target = info["target"]
378 cached = self._themes.get(target)
379 if cached is None:
380 cached = self._load_part(target)
381 if cached is not None:
382 self._themes[target] = cached
383 return cached
384 return None
385
386 def resolve_table_styles(self) -> PartRef | None:
387 """Return the presentation-level table style list, when usable.
388
389 Table style definitions are optional and some producers emit only the
390 built-in style id. A missing or malformed style part must therefore
391 not prevent otherwise valid slides from being converted.
392 """
393 if self._table_styles_loaded:
394 return self._table_styles
395 self._table_styles_loaded = True
396
397 target: str | None = None
398 if self.presentation is not None:
399 for info in self.presentation.rels.values():
400 if info.get("type") == REL_TYPES["tableStyles"]:
401 target = info.get("target")
402 break
403 if target is None:
404 target = "ppt/tableStyles.xml"
405
406 try:
407 self._table_styles = self._load_part(target)
408 except RuntimeError:
409 self._table_styles = None
410 return self._table_styles
411
412 # ------------------- public iteration -------------------
413
414 def iter_slides(self) -> Iterator[SlideRef]:
415 yield from self._slides
416
417 @property
418 def slide_count(self) -> int:
419 return len(self._slides)
420
421 def get_slide(self, index: int) -> SlideRef | None:
422 """1-based index lookup."""
423 if 1 <= index <= len(self._slides):
424 return self._slides[index - 1]
425 return None
426
427 def iter_all_masters(self) -> Iterator[PartRef]:
428 """Yield every slideMaster declared in presentation.xml, regardless of
429 whether any slide currently uses it.
430
431 Template decks routinely ship more masters than the visible sample
432 slides reference (one master per "style"). The slide-driven traversal
433 in ``_load_slides`` only caches masters that are actually consumed by
434 a slide — fine for an authoring deck, but it drops 90% of the design
435 intent for a multi-style template package. This iterator hits the
436 presentation's ``sldMasterIdLst`` directly so callers (e.g. the
437 layered template export) can preserve the full template library.
438 """
439 if self.presentation is None:
440 return
441 master_id_lst = self.presentation.xml.find("p:sldMasterIdLst", NS)
442 if master_id_lst is None:
443 return
444 for master_id in master_id_lst.findall("p:sldMasterId", NS):
445 rid = master_id.attrib.get(f"{{{NS['r']}}}id")
446 if not rid:
447 continue
448 target = self.presentation.resolve_rel(rid)
449 if not target:
450 continue
451 cached = self._masters.get(target)
452 if cached is None:
453 cached = self._load_part(target)
454 if cached is None:
455 continue
456 self._masters[target] = cached
457 yield cached
458
459 def iter_all_layouts(self) -> Iterator[PartRef]:
460 """Yield every slideLayout reachable from any master, regardless of
461 slide usage. Layouts live under ``master.sldLayoutIdLst`` in document
462 order; we walk every master so the export reflects the full set.
463
464 See :meth:`iter_all_layouts_with_parent` when you need each layout's
465 owning master alongside the layout itself (e.g. for theme-fill
466 resolution during standalone rendering).
467 """
468 for layout, _master in self.iter_all_layouts_with_parent():
469 yield layout
470
471 def iter_all_layouts_with_parent(self) -> Iterator[tuple[PartRef, PartRef]]:
472 """Like :meth:`iter_all_layouts` but yields ``(layout, parent_master)``
473 pairs. The parent master is the one whose ``sldLayoutIdLst`` contains
474 the layout, which is the source of truth for theme-style resolution
475 (e.g. ``<p:bgRef idx=...>`` on the layout still hops through the
476 master's theme).
477 """
478 seen: set[str] = set()
479 for master in self.iter_all_masters():
480 layout_id_lst = master.xml.find("p:sldLayoutIdLst", NS)
481 if layout_id_lst is None:
482 continue
483 for layout_id in layout_id_lst.findall("p:sldLayoutId", NS):
484 rid = layout_id.attrib.get(f"{{{NS['r']}}}id")
485 if not rid:
486 continue
487 target = master.resolve_rel(rid)
488 if not target or target in seen:
489 continue
490 seen.add(target)
491 cached = self._layouts.get(target)
492 if cached is None:
493 cached = self._load_part(target)
494 if cached is None:
495 continue
496 self._layouts[target] = cached
497 yield cached, master
498
498 lines PYTHON