返回 ppt-master
ooxml.py
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
237 lines PYTHON