Files

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