498 lines
19 KiB
Python
498 lines
19 KiB
Python
"""OOXML loader: read .pptx zip package, resolve relationships, expose
|
|
slide / layout / master / theme / media as a navigable structure.
|
|
|
|
Mirrors what manifest.py does for analysis, but keeps the parsed XML roots
|
|
accessible for downstream shape conversion.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import posixpath
|
|
import re
|
|
import zipfile
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path, PurePosixPath
|
|
from typing import Iterator
|
|
from xml.etree import ElementTree as ET
|
|
|
|
from .emu_units import NS, emu_attr_to_px
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Relationship parsing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
REL_NS = NS["rel"]
|
|
PACKAGE_REL = "_rels/.rels"
|
|
PRESENTATION_REL_PREFIX = "ppt/_rels/presentation.xml.rels"
|
|
|
|
|
|
def _normalize_part_path(target: str, base: str | None = None) -> str:
|
|
"""Resolve a relationship target against its rels source.
|
|
|
|
PPTX rels typically use paths relative to the rels file's parent directory,
|
|
e.g. slide1.xml.rels says target="../theme/theme1.xml". The result is
|
|
normalized to a posix path with no leading slash and no '..'.
|
|
"""
|
|
target = target.replace("\\", "/")
|
|
if target.startswith("/"):
|
|
return target.lstrip("/")
|
|
if base is None:
|
|
return target
|
|
base_dir = posixpath.dirname(base.rstrip("/"))
|
|
joined = posixpath.normpath(posixpath.join(base_dir, target))
|
|
return joined.lstrip("/")
|
|
|
|
|
|
def _rels_path_for(part_path: str) -> str:
|
|
"""Given a part path 'ppt/slides/slide1.xml', return its rels path."""
|
|
parent, name = posixpath.split(part_path)
|
|
if not parent:
|
|
return f"_rels/{name}.rels"
|
|
return f"{parent}/_rels/{name}.rels"
|
|
|
|
|
|
def _parse_rels(zf: zipfile.ZipFile, rels_path: str) -> dict[str, dict[str, str]]:
|
|
"""Parse a .rels file. Returns {rId: {'type': ..., 'target': absolute_part_path}}."""
|
|
if rels_path not in zf.namelist():
|
|
return {}
|
|
try:
|
|
root = ET.fromstring(zf.read(rels_path))
|
|
except ET.ParseError as exc:
|
|
raise RuntimeError(f"Invalid relationships XML in {rels_path}: {exc}") from exc
|
|
|
|
base = rels_path.replace("/_rels/", "/") # source part path
|
|
base = base[:-len(".rels")] # strip .rels suffix
|
|
rels: dict[str, dict[str, str]] = {}
|
|
for child in root.findall(f"{{{REL_NS}}}Relationship"):
|
|
rid = child.attrib.get("Id", "")
|
|
rtype = child.attrib.get("Type", "")
|
|
target = child.attrib.get("Target", "")
|
|
target_mode = child.attrib.get("TargetMode", "")
|
|
# External relationships (e.g. hyperlinks) keep the raw target.
|
|
if target_mode == "External":
|
|
rels[rid] = {"type": rtype, "target": target, "external": "1"}
|
|
continue
|
|
absolute = _normalize_part_path(target, base)
|
|
rels[rid] = {"type": rtype, "target": absolute}
|
|
return rels
|
|
|
|
|
|
def _load_xml(zf: zipfile.ZipFile, part_path: str) -> ET.Element | None:
|
|
if part_path not in zf.namelist():
|
|
return None
|
|
try:
|
|
return ET.fromstring(zf.read(part_path))
|
|
except ET.ParseError as exc:
|
|
raise RuntimeError(f"Invalid OOXML part {part_path}: {exc}") from exc
|
|
|
|
|
|
def blip_embed_relationship_ids(blip: ET.Element) -> tuple[str, ...]:
|
|
"""Return embedded image relationships in fidelity-preferred order.
|
|
|
|
Modern Office stores an editable SVG relationship in ``asvg:svgBlip``
|
|
while keeping a raster fallback on the owning ``a:blip``. Consumers must
|
|
try the SVG relationship first and retain the raster relationship only as
|
|
a compatibility fallback.
|
|
"""
|
|
embed_attr = f"{{{NS['r']}}}embed"
|
|
candidates = [
|
|
node.attrib.get(embed_attr)
|
|
for node in blip.findall(".//asvg:svgBlip", NS)
|
|
]
|
|
candidates.append(blip.attrib.get(embed_attr))
|
|
|
|
ordered: list[str] = []
|
|
for rel_id in candidates:
|
|
if rel_id and rel_id not in ordered:
|
|
ordered.append(rel_id)
|
|
return tuple(ordered)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Data classes for navigable parts
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@dataclass
|
|
class PartRef:
|
|
"""A loaded XML part with its rels resolved."""
|
|
|
|
path: str
|
|
xml: ET.Element
|
|
rels: dict[str, dict[str, str]] = field(default_factory=dict)
|
|
|
|
def resolve_rel(self, rid: str) -> str | None:
|
|
"""Resolve an rId to an absolute part path. Returns None if missing."""
|
|
info = self.rels.get(rid)
|
|
if info is None:
|
|
return None
|
|
if info.get("external"):
|
|
return None
|
|
return info.get("target")
|
|
|
|
|
|
@dataclass
|
|
class SlideRef:
|
|
"""One slide, with its layout / master chain attached."""
|
|
|
|
index: int # 1-based
|
|
part: PartRef
|
|
layout: PartRef | None
|
|
master: PartRef | None
|
|
|
|
|
|
def parse_ooxml_boolean(
|
|
raw: str | None,
|
|
*,
|
|
default: bool,
|
|
context: str,
|
|
) -> bool:
|
|
"""Parse one XML Schema boolean without silently accepting bad OOXML."""
|
|
if raw is None:
|
|
return default
|
|
token = raw.strip()
|
|
if token in {"1", "true"}:
|
|
return True
|
|
if token in {"0", "false"}:
|
|
return False
|
|
raise RuntimeError(f"{context}: invalid boolean value {raw!r}")
|
|
|
|
|
|
def part_show_master_sp(part: PartRef) -> bool:
|
|
"""Return one slide/layout part's raw ``showMasterSp`` semantic value."""
|
|
return parse_ooxml_boolean(
|
|
part.xml.attrib.get("showMasterSp"),
|
|
default=True,
|
|
context=f"{part.path} showMasterSp",
|
|
)
|
|
|
|
|
|
def inherited_shape_visibility(slide: SlideRef) -> tuple[bool, bool]:
|
|
"""Return effective ``(layout_shapes, master_shapes)`` visibility.
|
|
|
|
A slide-level false value suppresses both inherited shape trees. A
|
|
layout-level false value suppresses only its parent Master's shape tree.
|
|
Background inheritance is separate and intentionally not represented by
|
|
these booleans.
|
|
"""
|
|
show_layout_shapes = part_show_master_sp(slide.part)
|
|
layout_shows_master_shapes = (
|
|
part_show_master_sp(slide.layout)
|
|
if slide.layout is not None else True
|
|
)
|
|
show_master_shapes = (
|
|
show_layout_shapes
|
|
and slide.master is not None
|
|
and layout_shows_master_shapes
|
|
)
|
|
return show_layout_shapes, show_master_shapes
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# OoxmlPackage
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Relationship type constants
|
|
REL_TYPES = {
|
|
"presentation": "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument",
|
|
"slide": "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide",
|
|
"slideLayout": "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout",
|
|
"slideMaster": "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster",
|
|
"theme": "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme",
|
|
"tableStyles": "http://schemas.openxmlformats.org/officeDocument/2006/relationships/tableStyles",
|
|
"image": "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image",
|
|
"media": "http://schemas.openxmlformats.org/officeDocument/2006/relationships/media",
|
|
}
|
|
|
|
|
|
class OoxmlPackage:
|
|
"""Loaded .pptx ready for shape walking.
|
|
|
|
Usage:
|
|
with OoxmlPackage(Path("foo.pptx")) as pkg:
|
|
for slide in pkg.iter_slides():
|
|
root = slide.part.xml
|
|
...
|
|
"""
|
|
|
|
def __init__(self, pptx_path: Path) -> None:
|
|
self.path = pptx_path
|
|
self.zip: zipfile.ZipFile | None = None
|
|
self.presentation: PartRef | None = None
|
|
self.slide_size_px: tuple[float, float] = (1280.0, 720.0)
|
|
self.slide_size_emu: tuple[int, int] = (12192000, 6858000)
|
|
self.first_slide_number: int = 1
|
|
self._slides: list[SlideRef] = []
|
|
self._layouts: dict[str, PartRef] = {}
|
|
self._masters: dict[str, PartRef] = {}
|
|
self._themes: dict[str, PartRef] = {}
|
|
self._table_styles: PartRef | None = None
|
|
self._table_styles_loaded = False
|
|
|
|
# ------------------- context manager -------------------
|
|
|
|
def __enter__(self) -> "OoxmlPackage":
|
|
self.open()
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb) -> None:
|
|
self.close()
|
|
|
|
def open(self) -> None:
|
|
if self.zip is not None:
|
|
return
|
|
self.zip = zipfile.ZipFile(self.path, "r")
|
|
self._load_presentation()
|
|
self._load_slides()
|
|
|
|
def close(self) -> None:
|
|
if self.zip is not None:
|
|
self.zip.close()
|
|
self.zip = None
|
|
|
|
# ------------------- low-level helpers -------------------
|
|
|
|
def _load_part(self, part_path: str) -> PartRef | None:
|
|
assert self.zip is not None
|
|
xml = _load_xml(self.zip, part_path)
|
|
if xml is None:
|
|
return None
|
|
rels = _parse_rels(self.zip, _rels_path_for(part_path))
|
|
return PartRef(path=part_path, xml=xml, rels=rels)
|
|
|
|
def load_part(self, part_path: str) -> PartRef | None:
|
|
"""Load an arbitrary XML part by package path."""
|
|
return self._load_part(part_path)
|
|
|
|
def read_media(self, part_path: str) -> bytes | None:
|
|
"""Return raw bytes of an embedded media part (e.g. ppt/media/image1.png)."""
|
|
assert self.zip is not None
|
|
if part_path not in self.zip.namelist():
|
|
return None
|
|
return self.zip.read(part_path)
|
|
|
|
def media_filename(self, part_path: str) -> str:
|
|
"""Last segment of the media path, e.g. 'image1.png'."""
|
|
return PurePosixPath(part_path).name
|
|
|
|
# ------------------- loading sequence -------------------
|
|
|
|
def _load_presentation(self) -> None:
|
|
assert self.zip is not None
|
|
# /_rels/.rels -> presentation.xml
|
|
package_rels = _parse_rels(self.zip, PACKAGE_REL)
|
|
pres_path = None
|
|
for info in package_rels.values():
|
|
if info.get("type") == REL_TYPES["presentation"]:
|
|
pres_path = info["target"]
|
|
break
|
|
if pres_path is None:
|
|
pres_path = "ppt/presentation.xml"
|
|
|
|
self.presentation = self._load_part(pres_path)
|
|
if self.presentation is None:
|
|
raise RuntimeError(f"presentation.xml missing in {self.path}")
|
|
|
|
raw_first_slide_number = self.presentation.xml.attrib.get("firstSlideNum")
|
|
if raw_first_slide_number is not None:
|
|
first_slide_token = raw_first_slide_number.strip(" \t\r\n")
|
|
if re.fullmatch(r"[+-]?[0-9]+", first_slide_token) is None:
|
|
raise RuntimeError(
|
|
f"Invalid presentation firstSlideNum: {raw_first_slide_number!r}"
|
|
)
|
|
digits = first_slide_token.lstrip("+-").lstrip("0") or "0"
|
|
if len(digits) > 10:
|
|
raise RuntimeError("Presentation firstSlideNum is outside xsd:int")
|
|
first_slide_number = int(digits)
|
|
if first_slide_token.startswith("-"):
|
|
first_slide_number = -first_slide_number
|
|
if not -(2**31) <= first_slide_number <= 2**31 - 1:
|
|
raise RuntimeError(
|
|
f"Presentation firstSlideNum is outside xsd:int: {first_slide_number}"
|
|
)
|
|
self.first_slide_number = first_slide_number
|
|
|
|
# slide size
|
|
size = self.presentation.xml.find("p:sldSz", NS)
|
|
if size is not None:
|
|
cx = int(size.attrib.get("cx", "12192000"))
|
|
cy = int(size.attrib.get("cy", "6858000"))
|
|
self.slide_size_emu = (cx, cy)
|
|
self.slide_size_px = (cx / 9525.0, cy / 9525.0)
|
|
|
|
def _load_slides(self) -> None:
|
|
assert self.zip is not None and self.presentation is not None
|
|
# presentation.xml has <p:sldIdLst><p:sldId r:id="rId..."/></p:sldIdLst>
|
|
# in document order.
|
|
sld_id_lst = self.presentation.xml.find("p:sldIdLst", NS)
|
|
if sld_id_lst is None:
|
|
return
|
|
|
|
for index, sld_id in enumerate(sld_id_lst.findall("p:sldId", NS), start=1):
|
|
rid = sld_id.attrib.get(f"{{{NS['r']}}}id")
|
|
if not rid:
|
|
continue
|
|
slide_path = self.presentation.resolve_rel(rid)
|
|
if not slide_path:
|
|
continue
|
|
slide_part = self._load_part(slide_path)
|
|
if slide_part is None:
|
|
continue
|
|
layout = self._resolve_layout(slide_part)
|
|
master = self._resolve_master(layout) if layout else None
|
|
self._slides.append(SlideRef(
|
|
index=index, part=slide_part, layout=layout, master=master,
|
|
))
|
|
|
|
def _resolve_layout(self, slide: PartRef) -> PartRef | None:
|
|
for info in slide.rels.values():
|
|
if info.get("type") == REL_TYPES["slideLayout"]:
|
|
target = info["target"]
|
|
cached = self._layouts.get(target)
|
|
if cached is None:
|
|
cached = self._load_part(target)
|
|
if cached is not None:
|
|
self._layouts[target] = cached
|
|
return cached
|
|
return None
|
|
|
|
def _resolve_master(self, layout: PartRef) -> PartRef | None:
|
|
for info in layout.rels.values():
|
|
if info.get("type") == REL_TYPES["slideMaster"]:
|
|
target = info["target"]
|
|
cached = self._masters.get(target)
|
|
if cached is None:
|
|
cached = self._load_part(target)
|
|
if cached is not None:
|
|
self._masters[target] = cached
|
|
return cached
|
|
return None
|
|
|
|
def resolve_theme(self, master: PartRef | None) -> PartRef | None:
|
|
"""Return the theme part referenced by a slide master."""
|
|
if master is None:
|
|
return None
|
|
for info in master.rels.values():
|
|
if info.get("type") == REL_TYPES["theme"]:
|
|
target = info["target"]
|
|
cached = self._themes.get(target)
|
|
if cached is None:
|
|
cached = self._load_part(target)
|
|
if cached is not None:
|
|
self._themes[target] = cached
|
|
return cached
|
|
return None
|
|
|
|
def resolve_table_styles(self) -> PartRef | None:
|
|
"""Return the presentation-level table style list, when usable.
|
|
|
|
Table style definitions are optional and some producers emit only the
|
|
built-in style id. A missing or malformed style part must therefore
|
|
not prevent otherwise valid slides from being converted.
|
|
"""
|
|
if self._table_styles_loaded:
|
|
return self._table_styles
|
|
self._table_styles_loaded = True
|
|
|
|
target: str | None = None
|
|
if self.presentation is not None:
|
|
for info in self.presentation.rels.values():
|
|
if info.get("type") == REL_TYPES["tableStyles"]:
|
|
target = info.get("target")
|
|
break
|
|
if target is None:
|
|
target = "ppt/tableStyles.xml"
|
|
|
|
try:
|
|
self._table_styles = self._load_part(target)
|
|
except RuntimeError:
|
|
self._table_styles = None
|
|
return self._table_styles
|
|
|
|
# ------------------- public iteration -------------------
|
|
|
|
def iter_slides(self) -> Iterator[SlideRef]:
|
|
yield from self._slides
|
|
|
|
@property
|
|
def slide_count(self) -> int:
|
|
return len(self._slides)
|
|
|
|
def get_slide(self, index: int) -> SlideRef | None:
|
|
"""1-based index lookup."""
|
|
if 1 <= index <= len(self._slides):
|
|
return self._slides[index - 1]
|
|
return None
|
|
|
|
def iter_all_masters(self) -> Iterator[PartRef]:
|
|
"""Yield every slideMaster declared in presentation.xml, regardless of
|
|
whether any slide currently uses it.
|
|
|
|
Template decks routinely ship more masters than the visible sample
|
|
slides reference (one master per "style"). The slide-driven traversal
|
|
in ``_load_slides`` only caches masters that are actually consumed by
|
|
a slide — fine for an authoring deck, but it drops 90% of the design
|
|
intent for a multi-style template package. This iterator hits the
|
|
presentation's ``sldMasterIdLst`` directly so callers (e.g. the
|
|
layered template export) can preserve the full template library.
|
|
"""
|
|
if self.presentation is None:
|
|
return
|
|
master_id_lst = self.presentation.xml.find("p:sldMasterIdLst", NS)
|
|
if master_id_lst is None:
|
|
return
|
|
for master_id in master_id_lst.findall("p:sldMasterId", NS):
|
|
rid = master_id.attrib.get(f"{{{NS['r']}}}id")
|
|
if not rid:
|
|
continue
|
|
target = self.presentation.resolve_rel(rid)
|
|
if not target:
|
|
continue
|
|
cached = self._masters.get(target)
|
|
if cached is None:
|
|
cached = self._load_part(target)
|
|
if cached is None:
|
|
continue
|
|
self._masters[target] = cached
|
|
yield cached
|
|
|
|
def iter_all_layouts(self) -> Iterator[PartRef]:
|
|
"""Yield every slideLayout reachable from any master, regardless of
|
|
slide usage. Layouts live under ``master.sldLayoutIdLst`` in document
|
|
order; we walk every master so the export reflects the full set.
|
|
|
|
See :meth:`iter_all_layouts_with_parent` when you need each layout's
|
|
owning master alongside the layout itself (e.g. for theme-fill
|
|
resolution during standalone rendering).
|
|
"""
|
|
for layout, _master in self.iter_all_layouts_with_parent():
|
|
yield layout
|
|
|
|
def iter_all_layouts_with_parent(self) -> Iterator[tuple[PartRef, PartRef]]:
|
|
"""Like :meth:`iter_all_layouts` but yields ``(layout, parent_master)``
|
|
pairs. The parent master is the one whose ``sldLayoutIdLst`` contains
|
|
the layout, which is the source of truth for theme-style resolution
|
|
(e.g. ``<p:bgRef idx=...>`` on the layout still hops through the
|
|
master's theme).
|
|
"""
|
|
seen: set[str] = set()
|
|
for master in self.iter_all_masters():
|
|
layout_id_lst = master.xml.find("p:sldLayoutIdLst", NS)
|
|
if layout_id_lst is None:
|
|
continue
|
|
for layout_id in layout_id_lst.findall("p:sldLayoutId", NS):
|
|
rid = layout_id.attrib.get(f"{{{NS['r']}}}id")
|
|
if not rid:
|
|
continue
|
|
target = master.resolve_rel(rid)
|
|
if not target or target in seen:
|
|
continue
|
|
seen.add(target)
|
|
cached = self._layouts.get(target)
|
|
if cached is None:
|
|
cached = self._load_part(target)
|
|
if cached is None:
|
|
continue
|
|
self._layouts[target] = cached
|
|
yield cached, master
|