| 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 |