| 1 | """Shared OOXML primitives for the template-fill pipeline. |
| 2 | |
| 3 | Read-side helpers only: namespaces and content-type constants, part / |
| 4 | relationship resolution, EMU unit conversion, slide-shape discovery, and small |
| 5 | JSON readers / writers. Write-side package plumbing lives in ``package.py``. |
| 6 | """ |
| 7 | |
| 8 | from __future__ import annotations |
| 9 | |
| 10 | import json |
| 11 | import posixpath |
| 12 | import zipfile |
| 13 | from dataclasses import dataclass |
| 14 | from pathlib import Path |
| 15 | from typing import Any |
| 16 | from xml.etree import ElementTree as ET |
| 17 | |
| 18 | |
| 19 | NS = { |
| 20 | "a": "http://schemas.openxmlformats.org/drawingml/2006/main", |
| 21 | "c": "http://schemas.openxmlformats.org/drawingml/2006/chart", |
| 22 | "p": "http://schemas.openxmlformats.org/presentationml/2006/main", |
| 23 | "r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships", |
| 24 | } |
| 25 | REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships" |
| 26 | CT_NS = "http://schemas.openxmlformats.org/package/2006/content-types" |
| 27 | P14_NS = "http://schemas.microsoft.com/office/powerpoint/2010/main" |
| 28 | MC_NS = "http://schemas.openxmlformats.org/markup-compatibility/2006" |
| 29 | C14_NS = "http://schemas.microsoft.com/office/drawing/2007/8/2/chart" |
| 30 | C16_NS = "http://schemas.microsoft.com/office/drawing/2014/chart" |
| 31 | C16R2_NS = "http://schemas.microsoft.com/office/drawing/2015/06/chart" |
| 32 | |
| 33 | SLIDE_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" |
| 34 | NOTES_SLIDE_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide" |
| 35 | CHART_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart" |
| 36 | PACKAGE_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/package" |
| 37 | SLIDE_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.presentationml.slide+xml" |
| 38 | NOTES_SLIDE_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.presentationml.notesSlide+xml" |
| 39 | CHART_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.drawingml.chart+xml" |
| 40 | XLSX_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" |
| 41 | EMU_PER_INCH = 914400 |
| 42 | PX_PER_INCH = 96 |
| 43 | |
| 44 | |
| 45 | for prefix, uri in NS.items(): |
| 46 | ET.register_namespace(prefix, uri) |
| 47 | ET.register_namespace("", REL_NS) |
| 48 | ET.register_namespace("mc", MC_NS) |
| 49 | ET.register_namespace("c14", C14_NS) |
| 50 | ET.register_namespace("c16", C16_NS) |
| 51 | ET.register_namespace("c16r2", C16R2_NS) |
| 52 | ET.register_namespace("p14", P14_NS) |
| 53 | |
| 54 | |
| 55 | @dataclass(frozen=True) |
| 56 | class SlideRef: |
| 57 | """Presentation slide reference resolved from presentation.xml.rels.""" |
| 58 | |
| 59 | index: int |
| 60 | rel_id: str |
| 61 | target: str |
| 62 | part_name: str |
| 63 | rels_name: str |
| 64 | |
| 65 | |
| 66 | def _qn(namespace: str, tag: str) -> str: |
| 67 | return f"{{{namespace}}}{tag}" |
| 68 | |
| 69 | |
| 70 | def _read_xml(zf: zipfile.ZipFile, name: str) -> ET.Element: |
| 71 | try: |
| 72 | return ET.fromstring(zf.read(name)) |
| 73 | except KeyError as exc: |
| 74 | raise RuntimeError(f"Missing required PPTX part: {name}") from exc |
| 75 | |
| 76 | |
| 77 | def _xml_bytes(root: ET.Element) -> bytes: |
| 78 | root_namespace = root.tag[1:].split("}", 1)[0] if root.tag.startswith("{") else "" |
| 79 | if root_namespace in {REL_NS, CT_NS}: |
| 80 | # OPC relationship/content-type roots conventionally use the default |
| 81 | # namespace. ElementTree's global prefix registry can be changed while |
| 82 | # parsing source parts; restore the package-root form before writing so |
| 83 | # strict consumers such as LibreOffice accept the generated package. |
| 84 | ET.register_namespace("", root_namespace) |
| 85 | return ET.tostring(root, encoding="utf-8", xml_declaration=True) |
| 86 | |
| 87 | |
| 88 | def _normalize_part(target: str, base: str = "ppt/presentation.xml") -> str: |
| 89 | if target.startswith("/"): |
| 90 | return target.lstrip("/") |
| 91 | normalized = posixpath.normpath(posixpath.join(posixpath.dirname(base), target)) |
| 92 | return normalized.lstrip("/") |
| 93 | |
| 94 | |
| 95 | def _rels_name_for_part(part_name: str) -> str: |
| 96 | parent = posixpath.dirname(part_name) |
| 97 | basename = posixpath.basename(part_name) |
| 98 | return posixpath.join(parent, "_rels", f"{basename}.rels") |
| 99 | |
| 100 | |
| 101 | def _emu_to_px(value: str | None) -> int | None: |
| 102 | if not value: |
| 103 | return None |
| 104 | try: |
| 105 | return round(int(value) / EMU_PER_INCH * PX_PER_INCH) |
| 106 | except ValueError: |
| 107 | return None |
| 108 | |
| 109 | |
| 110 | def _parse_relationships(zf: zipfile.ZipFile) -> dict[str, dict[str, str]]: |
| 111 | rels_root = _read_xml(zf, "ppt/_rels/presentation.xml.rels") |
| 112 | relationships: dict[str, dict[str, str]] = {} |
| 113 | for rel in rels_root.findall(_qn(REL_NS, "Relationship")): |
| 114 | rel_id = rel.attrib.get("Id") |
| 115 | target = rel.attrib.get("Target") |
| 116 | rel_type = rel.attrib.get("Type") |
| 117 | if rel_id and target and rel_type: |
| 118 | relationships[rel_id] = {"target": target, "type": rel_type} |
| 119 | return relationships |
| 120 | |
| 121 | |
| 122 | def _parse_slide_refs(zf: zipfile.ZipFile) -> list[SlideRef]: |
| 123 | pres_root = _read_xml(zf, "ppt/presentation.xml") |
| 124 | relationships = _parse_relationships(zf) |
| 125 | sld_id_lst = pres_root.find("p:sldIdLst", NS) |
| 126 | if sld_id_lst is None: |
| 127 | return [] |
| 128 | |
| 129 | slides: list[SlideRef] = [] |
| 130 | for index, sld_id in enumerate(sld_id_lst.findall("p:sldId", NS), start=1): |
| 131 | rel_id = sld_id.attrib.get(_qn(NS["r"], "id")) |
| 132 | if not rel_id or rel_id not in relationships: |
| 133 | continue |
| 134 | rel = relationships[rel_id] |
| 135 | if rel["type"] != SLIDE_REL_TYPE: |
| 136 | continue |
| 137 | part_name = _normalize_part(rel["target"]) |
| 138 | slides.append( |
| 139 | SlideRef( |
| 140 | index=index, |
| 141 | rel_id=rel_id, |
| 142 | target=rel["target"], |
| 143 | part_name=part_name, |
| 144 | rels_name=_rels_name_for_part(part_name), |
| 145 | ) |
| 146 | ) |
| 147 | return slides |
| 148 | |
| 149 | |
| 150 | def _slide_relationships(zf: zipfile.ZipFile, rels_name: str) -> dict[str, dict[str, str]]: |
| 151 | try: |
| 152 | rels_root = _read_xml(zf, rels_name) |
| 153 | except RuntimeError: |
| 154 | return {} |
| 155 | relationships: dict[str, dict[str, str]] = {} |
| 156 | for rel in rels_root.findall(_qn(REL_NS, "Relationship")): |
| 157 | rel_id = rel.attrib.get("Id") |
| 158 | target = rel.attrib.get("Target") |
| 159 | rel_type = rel.attrib.get("Type") |
| 160 | if rel_id and target and rel_type: |
| 161 | relationships[rel_id] = {"target": target, "type": rel_type} |
| 162 | return relationships |
| 163 | |
| 164 | |
| 165 | def _paragraph_texts(container: ET.Element) -> list[str]: |
| 166 | paragraphs: list[str] = [] |
| 167 | for paragraph in container.findall(".//a:p", NS): |
| 168 | text = "".join(node.text or "" for node in paragraph.findall(".//a:t", NS)).strip() |
| 169 | if text: |
| 170 | paragraphs.append(text) |
| 171 | if paragraphs: |
| 172 | return paragraphs |
| 173 | text = "".join(node.text or "" for node in container.findall(".//a:t", NS)).strip() |
| 174 | return [text] if text else [] |
| 175 | |
| 176 | |
| 177 | def _container_geometry(container: ET.Element) -> dict[str, int | None]: |
| 178 | xfrm = container.find("p:spPr/a:xfrm", NS) |
| 179 | if xfrm is None: |
| 180 | xfrm = container.find("p:xfrm", NS) |
| 181 | if xfrm is None: |
| 182 | xfrm = container.find(".//a:xfrm", NS) |
| 183 | if xfrm is None: |
| 184 | return {"x": None, "y": None, "width": None, "height": None} |
| 185 | off = xfrm.find("a:off", NS) |
| 186 | ext = xfrm.find("a:ext", NS) |
| 187 | return { |
| 188 | "x": _emu_to_px(off.attrib.get("x")) if off is not None else None, |
| 189 | "y": _emu_to_px(off.attrib.get("y")) if off is not None else None, |
| 190 | "width": _emu_to_px(ext.attrib.get("cx")) if ext is not None else None, |
| 191 | "height": _emu_to_px(ext.attrib.get("cy")) if ext is not None else None, |
| 192 | } |
| 193 | |
| 194 | |
| 195 | def _text_containers(slide_root: ET.Element) -> list[ET.Element]: |
| 196 | containers: list[ET.Element] = [] |
| 197 | for tag in ("p:sp", "p:graphicFrame"): |
| 198 | for element in slide_root.findall(f".//{tag}", NS): |
| 199 | if element.find(".//p:txBody", NS) is not None or element.findall(".//a:t", NS): |
| 200 | containers.append(element) |
| 201 | return containers |
| 202 | |
| 203 | |
| 204 | def _table_containers(slide_root: ET.Element) -> list[ET.Element]: |
| 205 | return [ |
| 206 | frame |
| 207 | for frame in slide_root.findall(".//p:graphicFrame", NS) |
| 208 | if frame.find(".//a:tbl", NS) is not None |
| 209 | ] |
| 210 | |
| 211 | |
| 212 | def _chart_containers(slide_root: ET.Element) -> list[ET.Element]: |
| 213 | return [ |
| 214 | frame |
| 215 | for frame in slide_root.findall(".//p:graphicFrame", NS) |
| 216 | if frame.find(".//c:chart", NS) is not None |
| 217 | ] |
| 218 | |
| 219 | |
| 220 | def _shape_identity(container: ET.Element, order: int) -> tuple[str, str]: |
| 221 | c_nv_pr = container.find(".//p:cNvPr", NS) |
| 222 | shape_id = c_nv_pr.attrib.get("id") if c_nv_pr is not None else str(order) |
| 223 | shape_name = c_nv_pr.attrib.get("name") if c_nv_pr is not None else "" |
| 224 | return shape_id, shape_name |
| 225 | |
| 226 | |
| 227 | def _load_json(path: Path) -> dict[str, Any]: |
| 228 | try: |
| 229 | return json.loads(path.read_text(encoding="utf-8")) |
| 230 | except json.JSONDecodeError as exc: |
| 231 | raise RuntimeError(f"Invalid JSON: {path}: {exc}") from exc |
| 232 | |
| 233 | |
| 234 | def _write_json(path: Path, payload: dict[str, Any]) -> None: |
| 235 | path.parent.mkdir(parents=True, exist_ok=True) |
| 236 | path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") |
| 237 |