返回 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_part_bytes(self, part_path: str) -> bytes | None:
268 """Return the unchanged bytes of one internal package part."""
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 read_media(self, part_path: str) -> bytes | None:
275 """Return raw bytes of an embedded media part (e.g. ppt/media/image1.png)."""
276 return self.read_part_bytes(part_path)
277
278 def media_filename(self, part_path: str) -> str:
279 """Last segment of the media path, e.g. 'image1.png'."""
280 return PurePosixPath(part_path).name
281
282 # ------------------- loading sequence -------------------
283
284 def _load_presentation(self) -> None:
285 assert self.zip is not None
286 # /_rels/.rels -> presentation.xml
287 package_rels = _parse_rels(self.zip, PACKAGE_REL)
288 pres_path = None
289 for info in package_rels.values():
290 if info.get("type") == REL_TYPES["presentation"]:
291 pres_path = info["target"]
292 break
293 if pres_path is None:
294 pres_path = "ppt/presentation.xml"
295
296 self.presentation = self._load_part(pres_path)
297 if self.presentation is None:
298 raise RuntimeError(f"presentation.xml missing in {self.path}")
299
300 raw_first_slide_number = self.presentation.xml.attrib.get("firstSlideNum")
301 if raw_first_slide_number is not None:
302 first_slide_token = raw_first_slide_number.strip(" \t\r\n")
303 if re.fullmatch(r"[+-]?[0-9]+", first_slide_token) is None:
304 raise RuntimeError(
305 f"Invalid presentation firstSlideNum: {raw_first_slide_number!r}"
306 )
307 digits = first_slide_token.lstrip("+-").lstrip("0") or "0"
308 if len(digits) > 10:
309 raise RuntimeError("Presentation firstSlideNum is outside xsd:int")
310 first_slide_number = int(digits)
311 if first_slide_token.startswith("-"):
312 first_slide_number = -first_slide_number
313 if not -(2**31) <= first_slide_number <= 2**31 - 1:
314 raise RuntimeError(
315 f"Presentation firstSlideNum is outside xsd:int: {first_slide_number}"
316 )
317 self.first_slide_number = first_slide_number
318
319 # slide size
320 size = self.presentation.xml.find("p:sldSz", NS)
321 if size is not None:
322 cx = int(size.attrib.get("cx", "12192000"))
323 cy = int(size.attrib.get("cy", "6858000"))
324 self.slide_size_emu = (cx, cy)
325 self.slide_size_px = (cx / 9525.0, cy / 9525.0)
326
327 def _load_slides(self) -> None:
328 assert self.zip is not None and self.presentation is not None
329 # presentation.xml has <p:sldIdLst><p:sldId r:id="rId..."/></p:sldIdLst>
330 # in document order.
331 sld_id_lst = self.presentation.xml.find("p:sldIdLst", NS)
332 if sld_id_lst is None:
333 return
334
335 for index, sld_id in enumerate(sld_id_lst.findall("p:sldId", NS), start=1):
336 rid = sld_id.attrib.get(f"{{{NS['r']}}}id")
337 if not rid:
338 continue
339 slide_path = self.presentation.resolve_rel(rid)
340 if not slide_path:
341 continue
342 slide_part = self._load_part(slide_path)
343 if slide_part is None:
344 continue
345 layout = self._resolve_layout(slide_part)
346 master = self._resolve_master(layout) if layout else None
347 self._slides.append(SlideRef(
348 index=index, part=slide_part, layout=layout, master=master,
349 ))
350
351 def _resolve_layout(self, slide: PartRef) -> PartRef | None:
352 for info in slide.rels.values():
353 if info.get("type") == REL_TYPES["slideLayout"]:
354 target = info["target"]
355 cached = self._layouts.get(target)
356 if cached is None:
357 cached = self._load_part(target)
358 if cached is not None:
359 self._layouts[target] = cached
360 return cached
361 return None
362
363 def _resolve_master(self, layout: PartRef) -> PartRef | None:
364 for info in layout.rels.values():
365 if info.get("type") == REL_TYPES["slideMaster"]:
366 target = info["target"]
367 cached = self._masters.get(target)
368 if cached is None:
369 cached = self._load_part(target)
370 if cached is not None:
371 self._masters[target] = cached
372 return cached
373 return None
374
375 def resolve_theme(self, master: PartRef | None) -> PartRef | None:
376 """Return the theme part referenced by a slide master."""
377 if master is None:
378 return None
379 for info in master.rels.values():
380 if info.get("type") == REL_TYPES["theme"]:
381 target = info["target"]
382 cached = self._themes.get(target)
383 if cached is None:
384 cached = self._load_part(target)
385 if cached is not None:
386 self._themes[target] = cached
387 return cached
388 return None
389
390 def resolve_table_styles(self) -> PartRef | None:
391 """Return the presentation-level table style list, when usable.
392
393 Table style definitions are optional and some producers emit only the
394 built-in style id. A missing or malformed style part must therefore
395 not prevent otherwise valid slides from being converted.
396 """
397 if self._table_styles_loaded:
398 return self._table_styles
399 self._table_styles_loaded = True
400
401 target: str | None = None
402 if self.presentation is not None:
403 for info in self.presentation.rels.values():
404 if info.get("type") == REL_TYPES["tableStyles"]:
405 target = info.get("target")
406 break
407 if target is None:
408 target = "ppt/tableStyles.xml"
409
410 try:
411 self._table_styles = self._load_part(target)
412 except RuntimeError:
413 self._table_styles = None
414 return self._table_styles
415
416 # ------------------- public iteration -------------------
417
418 def iter_slides(self) -> Iterator[SlideRef]:
419 yield from self._slides
420
421 @property
422 def slide_count(self) -> int:
423 return len(self._slides)
424
425 def get_slide(self, index: int) -> SlideRef | None:
426 """1-based index lookup."""
427 if 1 <= index <= len(self._slides):
428 return self._slides[index - 1]
429 return None
430
431 @property
432 def slide_index_by_part(self) -> dict[str, int]:
433 """Return final presentation-order indices keyed by slide part path."""
434 return {slide.part.path: slide.index for slide in self._slides}
435
436 def iter_all_masters(self) -> Iterator[PartRef]:
437 """Yield every slideMaster declared in presentation.xml, regardless of
438 whether any slide currently uses it.
439
440 Template decks routinely ship more masters than the visible sample
441 slides reference (one master per "style"). The slide-driven traversal
442 in ``_load_slides`` only caches masters that are actually consumed by
443 a slide — fine for an authoring deck, but it drops 90% of the design
444 intent for a multi-style template package. This iterator hits the
445 presentation's ``sldMasterIdLst`` directly so callers (e.g. the
446 layered template export) can preserve the full template library.
447 """
448 if self.presentation is None:
449 return
450 master_id_lst = self.presentation.xml.find("p:sldMasterIdLst", NS)
451 if master_id_lst is None:
452 return
453 for master_id in master_id_lst.findall("p:sldMasterId", NS):
454 rid = master_id.attrib.get(f"{{{NS['r']}}}id")
455 if not rid:
456 continue
457 target = self.presentation.resolve_rel(rid)
458 if not target:
459 continue
460 cached = self._masters.get(target)
461 if cached is None:
462 cached = self._load_part(target)
463 if cached is None:
464 continue
465 self._masters[target] = cached
466 yield cached
467
468 def iter_all_layouts(self) -> Iterator[PartRef]:
469 """Yield every slideLayout reachable from any master, regardless of
470 slide usage. Layouts live under ``master.sldLayoutIdLst`` in document
471 order; we walk every master so the export reflects the full set.
472
473 See :meth:`iter_all_layouts_with_parent` when you need each layout's
474 owning master alongside the layout itself (e.g. for theme-fill
475 resolution during standalone rendering).
476 """
477 for layout, _master in self.iter_all_layouts_with_parent():
478 yield layout
479
480 def iter_all_layouts_with_parent(self) -> Iterator[tuple[PartRef, PartRef]]:
481 """Like :meth:`iter_all_layouts` but yields ``(layout, parent_master)``
482 pairs. The parent master is the one whose ``sldLayoutIdLst`` contains
483 the layout, which is the source of truth for theme-style resolution
484 (e.g. ``<p:bgRef idx=...>`` on the layout still hops through the
485 master's theme).
486 """
487 seen: set[str] = set()
488 for master in self.iter_all_masters():
489 layout_id_lst = master.xml.find("p:sldLayoutIdLst", NS)
490 if layout_id_lst is None:
491 continue
492 for layout_id in layout_id_lst.findall("p:sldLayoutId", NS):
493 rid = layout_id.attrib.get(f"{{{NS['r']}}}id")
494 if not rid:
495 continue
496 target = master.resolve_rel(rid)
497 if not target or target in seen:
498 continue
499 seen.add(target)
500 cached = self._layouts.get(target)
501 if cached is None:
502 cached = self._load_part(target)
503 if cached is None:
504 continue
505 self._layouts[target] = cached
506 yield cached, master
507
507 lines PYTHON