237 lines
8.7 KiB
Python
237 lines
8.7 KiB
Python
"""Shared OOXML primitives for the template-fill pipeline.
|
|
|
|
Read-side helpers only: namespaces and content-type constants, part /
|
|
relationship resolution, EMU unit conversion, slide-shape discovery, and small
|
|
JSON readers / writers. Write-side package plumbing lives in ``package.py``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import posixpath
|
|
import zipfile
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from xml.etree import ElementTree as ET
|
|
|
|
|
|
NS = {
|
|
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
|
|
"c": "http://schemas.openxmlformats.org/drawingml/2006/chart",
|
|
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
|
|
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
|
|
}
|
|
REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
|
|
CT_NS = "http://schemas.openxmlformats.org/package/2006/content-types"
|
|
P14_NS = "http://schemas.microsoft.com/office/powerpoint/2010/main"
|
|
MC_NS = "http://schemas.openxmlformats.org/markup-compatibility/2006"
|
|
C14_NS = "http://schemas.microsoft.com/office/drawing/2007/8/2/chart"
|
|
C16_NS = "http://schemas.microsoft.com/office/drawing/2014/chart"
|
|
C16R2_NS = "http://schemas.microsoft.com/office/drawing/2015/06/chart"
|
|
|
|
SLIDE_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide"
|
|
NOTES_SLIDE_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide"
|
|
CHART_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart"
|
|
PACKAGE_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/package"
|
|
SLIDE_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.presentationml.slide+xml"
|
|
NOTES_SLIDE_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.presentationml.notesSlide+xml"
|
|
CHART_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.drawingml.chart+xml"
|
|
XLSX_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
EMU_PER_INCH = 914400
|
|
PX_PER_INCH = 96
|
|
|
|
|
|
for prefix, uri in NS.items():
|
|
ET.register_namespace(prefix, uri)
|
|
ET.register_namespace("", REL_NS)
|
|
ET.register_namespace("mc", MC_NS)
|
|
ET.register_namespace("c14", C14_NS)
|
|
ET.register_namespace("c16", C16_NS)
|
|
ET.register_namespace("c16r2", C16R2_NS)
|
|
ET.register_namespace("p14", P14_NS)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SlideRef:
|
|
"""Presentation slide reference resolved from presentation.xml.rels."""
|
|
|
|
index: int
|
|
rel_id: str
|
|
target: str
|
|
part_name: str
|
|
rels_name: str
|
|
|
|
|
|
def _qn(namespace: str, tag: str) -> str:
|
|
return f"{{{namespace}}}{tag}"
|
|
|
|
|
|
def _read_xml(zf: zipfile.ZipFile, name: str) -> ET.Element:
|
|
try:
|
|
return ET.fromstring(zf.read(name))
|
|
except KeyError as exc:
|
|
raise RuntimeError(f"Missing required PPTX part: {name}") from exc
|
|
|
|
|
|
def _xml_bytes(root: ET.Element) -> bytes:
|
|
root_namespace = root.tag[1:].split("}", 1)[0] if root.tag.startswith("{") else ""
|
|
if root_namespace in {REL_NS, CT_NS}:
|
|
# OPC relationship/content-type roots conventionally use the default
|
|
# namespace. ElementTree's global prefix registry can be changed while
|
|
# parsing source parts; restore the package-root form before writing so
|
|
# strict consumers such as LibreOffice accept the generated package.
|
|
ET.register_namespace("", root_namespace)
|
|
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
|
|
|
|
|
|
def _normalize_part(target: str, base: str = "ppt/presentation.xml") -> str:
|
|
if target.startswith("/"):
|
|
return target.lstrip("/")
|
|
normalized = posixpath.normpath(posixpath.join(posixpath.dirname(base), target))
|
|
return normalized.lstrip("/")
|
|
|
|
|
|
def _rels_name_for_part(part_name: str) -> str:
|
|
parent = posixpath.dirname(part_name)
|
|
basename = posixpath.basename(part_name)
|
|
return posixpath.join(parent, "_rels", f"{basename}.rels")
|
|
|
|
|
|
def _emu_to_px(value: str | None) -> int | None:
|
|
if not value:
|
|
return None
|
|
try:
|
|
return round(int(value) / EMU_PER_INCH * PX_PER_INCH)
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def _parse_relationships(zf: zipfile.ZipFile) -> dict[str, dict[str, str]]:
|
|
rels_root = _read_xml(zf, "ppt/_rels/presentation.xml.rels")
|
|
relationships: dict[str, dict[str, str]] = {}
|
|
for rel in rels_root.findall(_qn(REL_NS, "Relationship")):
|
|
rel_id = rel.attrib.get("Id")
|
|
target = rel.attrib.get("Target")
|
|
rel_type = rel.attrib.get("Type")
|
|
if rel_id and target and rel_type:
|
|
relationships[rel_id] = {"target": target, "type": rel_type}
|
|
return relationships
|
|
|
|
|
|
def _parse_slide_refs(zf: zipfile.ZipFile) -> list[SlideRef]:
|
|
pres_root = _read_xml(zf, "ppt/presentation.xml")
|
|
relationships = _parse_relationships(zf)
|
|
sld_id_lst = pres_root.find("p:sldIdLst", NS)
|
|
if sld_id_lst is None:
|
|
return []
|
|
|
|
slides: list[SlideRef] = []
|
|
for index, sld_id in enumerate(sld_id_lst.findall("p:sldId", NS), start=1):
|
|
rel_id = sld_id.attrib.get(_qn(NS["r"], "id"))
|
|
if not rel_id or rel_id not in relationships:
|
|
continue
|
|
rel = relationships[rel_id]
|
|
if rel["type"] != SLIDE_REL_TYPE:
|
|
continue
|
|
part_name = _normalize_part(rel["target"])
|
|
slides.append(
|
|
SlideRef(
|
|
index=index,
|
|
rel_id=rel_id,
|
|
target=rel["target"],
|
|
part_name=part_name,
|
|
rels_name=_rels_name_for_part(part_name),
|
|
)
|
|
)
|
|
return slides
|
|
|
|
|
|
def _slide_relationships(zf: zipfile.ZipFile, rels_name: str) -> dict[str, dict[str, str]]:
|
|
try:
|
|
rels_root = _read_xml(zf, rels_name)
|
|
except RuntimeError:
|
|
return {}
|
|
relationships: dict[str, dict[str, str]] = {}
|
|
for rel in rels_root.findall(_qn(REL_NS, "Relationship")):
|
|
rel_id = rel.attrib.get("Id")
|
|
target = rel.attrib.get("Target")
|
|
rel_type = rel.attrib.get("Type")
|
|
if rel_id and target and rel_type:
|
|
relationships[rel_id] = {"target": target, "type": rel_type}
|
|
return relationships
|
|
|
|
|
|
def _paragraph_texts(container: ET.Element) -> list[str]:
|
|
paragraphs: list[str] = []
|
|
for paragraph in container.findall(".//a:p", NS):
|
|
text = "".join(node.text or "" for node in paragraph.findall(".//a:t", NS)).strip()
|
|
if text:
|
|
paragraphs.append(text)
|
|
if paragraphs:
|
|
return paragraphs
|
|
text = "".join(node.text or "" for node in container.findall(".//a:t", NS)).strip()
|
|
return [text] if text else []
|
|
|
|
|
|
def _container_geometry(container: ET.Element) -> dict[str, int | None]:
|
|
xfrm = container.find("p:spPr/a:xfrm", NS)
|
|
if xfrm is None:
|
|
xfrm = container.find("p:xfrm", NS)
|
|
if xfrm is None:
|
|
xfrm = container.find(".//a:xfrm", NS)
|
|
if xfrm is None:
|
|
return {"x": None, "y": None, "width": None, "height": None}
|
|
off = xfrm.find("a:off", NS)
|
|
ext = xfrm.find("a:ext", NS)
|
|
return {
|
|
"x": _emu_to_px(off.attrib.get("x")) if off is not None else None,
|
|
"y": _emu_to_px(off.attrib.get("y")) if off is not None else None,
|
|
"width": _emu_to_px(ext.attrib.get("cx")) if ext is not None else None,
|
|
"height": _emu_to_px(ext.attrib.get("cy")) if ext is not None else None,
|
|
}
|
|
|
|
|
|
def _text_containers(slide_root: ET.Element) -> list[ET.Element]:
|
|
containers: list[ET.Element] = []
|
|
for tag in ("p:sp", "p:graphicFrame"):
|
|
for element in slide_root.findall(f".//{tag}", NS):
|
|
if element.find(".//p:txBody", NS) is not None or element.findall(".//a:t", NS):
|
|
containers.append(element)
|
|
return containers
|
|
|
|
|
|
def _table_containers(slide_root: ET.Element) -> list[ET.Element]:
|
|
return [
|
|
frame
|
|
for frame in slide_root.findall(".//p:graphicFrame", NS)
|
|
if frame.find(".//a:tbl", NS) is not None
|
|
]
|
|
|
|
|
|
def _chart_containers(slide_root: ET.Element) -> list[ET.Element]:
|
|
return [
|
|
frame
|
|
for frame in slide_root.findall(".//p:graphicFrame", NS)
|
|
if frame.find(".//c:chart", NS) is not None
|
|
]
|
|
|
|
|
|
def _shape_identity(container: ET.Element, order: int) -> tuple[str, str]:
|
|
c_nv_pr = container.find(".//p:cNvPr", NS)
|
|
shape_id = c_nv_pr.attrib.get("id") if c_nv_pr is not None else str(order)
|
|
shape_name = c_nv_pr.attrib.get("name") if c_nv_pr is not None else ""
|
|
return shape_id, shape_name
|
|
|
|
|
|
def _load_json(path: Path) -> dict[str, Any]:
|
|
try:
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
except json.JSONDecodeError as exc:
|
|
raise RuntimeError(f"Invalid JSON: {path}: {exc}") from exc
|
|
|
|
|
|
def _write_json(path: Path, payload: dict[str, Any]) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|