返回 ppt-master
loader.py
1 #!/usr/bin/env python3
2 """
3 PPT Master - Preset Shape Data Loader
4
5 Load and validate the bundled DrawingML preset geometry catalog.
6
7 Usage:
8 Import load_preset_shape_definitions from pptx_shapes.loader.
9
10 Examples:
11 definitions = load_preset_shape_definitions()
12
13 Dependencies:
14 None (only uses standard library)
15 """
16
17 from __future__ import annotations
18
19 import hashlib
20 from pathlib import Path
21 from xml.etree import ElementTree as ET
22
23 from .errors import PresetShapeDataError
24 from .models import (
25 AdjustHandleDefinition,
26 ConnectionSiteDefinition,
27 GuideDefinition,
28 PathCommandDefinition,
29 PointExpression,
30 PresetShapeDefinition,
31 ShapePathDefinition,
32 TextRectangleDefinition,
33 )
34
35
36 DRAWINGML_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
37 EXPECTED_SHAPE_COUNT = 187
38 BUNDLED_DEFINITIONS_SHA256 = (
39 "4a762444d8d85876881c02a5b1dedf6f73006fcd8acb7b4e393435615b37c780"
40 )
41 BUNDLED_SHAPE_TYPES_SHA256 = (
42 "f2c3bdcda8569b358ce3196cfeb183849e33bfc7955fac961dc85fceb6b3b587"
43 )
44
45 _PACKAGE_DIR = Path(__file__).resolve().parent
46 BUNDLED_DEFINITIONS_PATH = _PACKAGE_DIR / "data" / "presetShapeDefinitions.xml"
47 BUNDLED_SHAPE_TYPES_PATH = _PACKAGE_DIR / "data" / "shape_type_values.txt"
48 _EXPECTED_ROOT_TAG = "presetShapeDefinitons"
49 _PATH_COMMAND_ARITY = {
50 "moveTo": 1,
51 "lnTo": 1,
52 "quadBezTo": 2,
53 "cubicBezTo": 3,
54 "arcTo": 0,
55 "close": 0,
56 }
57
58
59 def load_shape_type_values(path: Path | None = None) -> tuple[str, ...]:
60 """Load the independent Open XML ``ShapeTypeValues`` coverage list."""
61
62 source = path or BUNDLED_SHAPE_TYPES_PATH
63 try:
64 raw = source.read_bytes()
65 names = tuple(
66 line.strip()
67 for line in raw.decode("utf-8").splitlines()
68 if line.strip() and not line.lstrip().startswith("#")
69 )
70 except (OSError, UnicodeDecodeError) as exc:
71 raise PresetShapeDataError(
72 f"Cannot read preset shape type catalog {source}: {exc}"
73 ) from exc
74 if path is None:
75 actual = hashlib.sha256(_normalized_lf_bytes(raw)).hexdigest()
76 if actual != BUNDLED_SHAPE_TYPES_SHA256:
77 raise PresetShapeDataError(
78 f"ShapeTypeValues checksum mismatch for {source}: "
79 f"expected {BUNDLED_SHAPE_TYPES_SHA256}, found {actual}"
80 )
81 if len(names) != len(set(names)):
82 raise PresetShapeDataError(f"Duplicate names in shape type catalog: {source}")
83 if len(names) != EXPECTED_SHAPE_COUNT:
84 raise PresetShapeDataError(
85 f"Expected {EXPECTED_SHAPE_COUNT} ShapeTypeValues, found {len(names)}"
86 )
87 return names
88
89
90 def load_preset_shape_definitions(
91 path: Path | None = None,
92 *,
93 expected_sha256: str | None = None,
94 expected_names: tuple[str, ...] | None = None,
95 ) -> tuple[PresetShapeDefinition, ...]:
96 """Load one preset XML catalog and enforce uniqueness and coverage.
97
98 The bundled catalog is hash-locked automatically. External catalogs are
99 validated structurally and can opt into a caller-provided hash.
100 """
101
102 source = path or BUNDLED_DEFINITIONS_PATH
103 locked_hash = (
104 BUNDLED_DEFINITIONS_SHA256
105 if path is None and expected_sha256 is None
106 else expected_sha256
107 )
108 raw = _read_verified_bytes(source, locked_hash)
109 try:
110 root = ET.fromstring(raw)
111 except ET.ParseError as exc:
112 raise PresetShapeDataError(f"Invalid preset geometry XML {source}: {exc}") from exc
113 if _local_name(root.tag) != _EXPECTED_ROOT_TAG:
114 raise PresetShapeDataError(
115 f"Unexpected preset geometry root {_local_name(root.tag)!r} in {source}"
116 )
117
118 definitions = tuple(_parse_shape(element) for element in root)
119 names = tuple(definition.name for definition in definitions)
120 if len(names) != len(set(names)):
121 raise PresetShapeDataError(f"Duplicate preset geometry names in {source}")
122
123 catalog_names = expected_names
124 if catalog_names is None and path is None:
125 catalog_names = load_shape_type_values()
126 if catalog_names is not None:
127 missing = sorted(set(catalog_names) - set(names))
128 extra = sorted(set(names) - set(catalog_names))
129 if missing or extra:
130 raise PresetShapeDataError(
131 "Preset geometry coverage differs from ShapeTypeValues: "
132 f"missing={missing}, extra={extra}"
133 )
134 return definitions
135
136
137 def _read_verified_bytes(path: Path, expected_sha256: str | None) -> bytes:
138 try:
139 raw = path.read_bytes()
140 except OSError as exc:
141 raise PresetShapeDataError(f"Cannot read preset geometry data {path}: {exc}") from exc
142 if expected_sha256:
143 actual = hashlib.sha256(_normalized_lf_bytes(raw)).hexdigest()
144 if actual != expected_sha256:
145 raise PresetShapeDataError(
146 f"Preset geometry checksum mismatch for {path}: "
147 f"expected {expected_sha256}, found {actual}"
148 )
149 return raw
150
151
152 def _normalized_lf_bytes(raw: bytes) -> bytes:
153 """Normalize checkout line endings before verifying text-resource hashes."""
154
155 return raw.replace(b"\r\n", b"\n").replace(b"\r", b"\n")
156
157
158 def _parse_shape(element: ET.Element) -> PresetShapeDefinition:
159 name = _local_name(element.tag)
160 if not name:
161 raise PresetShapeDataError("Preset shape name must not be empty")
162 adjustments = _parse_guides(_find_child(element, "avLst"))
163 guides = _parse_guides(_find_child(element, "gdLst"))
164 handles = _parse_handles(_find_child(element, "ahLst"))
165 connections = _parse_connections(_find_child(element, "cxnLst"))
166 text_rectangle = _parse_text_rectangle(_find_child(element, "rect"))
167 paths = _parse_paths(_find_child(element, "pathLst"), name)
168 return PresetShapeDefinition(
169 name=name,
170 adjustments=adjustments,
171 guides=guides,
172 handles=handles,
173 connections=connections,
174 text_rectangle=text_rectangle,
175 paths=paths,
176 )
177
178
179 def _parse_guides(container: ET.Element | None) -> tuple[GuideDefinition, ...]:
180 if container is None:
181 return ()
182 guides = []
183 for element in container:
184 if _local_name(element.tag) != "gd":
185 raise PresetShapeDataError(
186 f"Unexpected {_local_name(element.tag)!r} in guide list"
187 )
188 name = _required_attribute(element, "name")
189 formula = _required_attribute(element, "fmla")
190 guides.append(GuideDefinition(name=name, formula=formula))
191 # Some normative preset definitions intentionally rebind an intermediate
192 # name later in the ordered guide list. Preserve that sequential behavior.
193 return tuple(guides)
194
195
196 def _parse_handles(
197 container: ET.Element | None,
198 ) -> tuple[AdjustHandleDefinition, ...]:
199 if container is None:
200 return ()
201 handles = []
202 for element in container:
203 kind = _local_name(element.tag)
204 if kind not in {"ahXY", "ahPolar"}:
205 raise PresetShapeDataError(f"Unexpected adjustment handle: {kind!r}")
206 position = _parse_position(element)
207 handles.append(
208 AdjustHandleDefinition(
209 kind="xy" if kind == "ahXY" else "polar",
210 position=position,
211 x_reference=element.attrib.get("gdRefX"),
212 minimum_x=element.attrib.get("minX"),
213 maximum_x=element.attrib.get("maxX"),
214 y_reference=element.attrib.get("gdRefY"),
215 minimum_y=element.attrib.get("minY"),
216 maximum_y=element.attrib.get("maxY"),
217 angle_reference=element.attrib.get("gdRefAng"),
218 minimum_angle=element.attrib.get("minAng"),
219 maximum_angle=element.attrib.get("maxAng"),
220 radius_reference=element.attrib.get("gdRefR"),
221 minimum_radius=element.attrib.get("minR"),
222 maximum_radius=element.attrib.get("maxR"),
223 )
224 )
225 return tuple(handles)
226
227
228 def _parse_connections(
229 container: ET.Element | None,
230 ) -> tuple[ConnectionSiteDefinition, ...]:
231 if container is None:
232 return ()
233 connections = []
234 for element in container:
235 if _local_name(element.tag) != "cxn":
236 raise PresetShapeDataError(
237 f"Unexpected connection-site element: {_local_name(element.tag)!r}"
238 )
239 connections.append(
240 ConnectionSiteDefinition(
241 angle=_required_attribute(element, "ang"),
242 position=_parse_position(element),
243 )
244 )
245 return tuple(connections)
246
247
248 def _parse_text_rectangle(
249 element: ET.Element | None,
250 ) -> TextRectangleDefinition | None:
251 if element is None:
252 return None
253 return TextRectangleDefinition(
254 left=_required_attribute(element, "l"),
255 top=_required_attribute(element, "t"),
256 right=_required_attribute(element, "r"),
257 bottom=_required_attribute(element, "b"),
258 )
259
260
261 def _parse_paths(
262 container: ET.Element | None,
263 shape_name: str,
264 ) -> tuple[ShapePathDefinition, ...]:
265 if container is None:
266 raise PresetShapeDataError(f"Preset {shape_name!r} has no path list")
267 paths = []
268 for element in container:
269 if _local_name(element.tag) != "path":
270 raise PresetShapeDataError(
271 f"Unexpected path-list element: {_local_name(element.tag)!r}"
272 )
273 paths.append(
274 ShapePathDefinition(
275 coordinate_width=element.attrib.get("w"),
276 coordinate_height=element.attrib.get("h"),
277 fill=element.attrib.get("fill", "norm"),
278 stroke=_parse_boolean(element.attrib.get("stroke"), default=True),
279 extrusion_ok=_parse_boolean(
280 element.attrib.get("extrusionOk"),
281 default=True,
282 ),
283 commands=tuple(_parse_path_command(command) for command in element),
284 )
285 )
286 if not paths:
287 raise PresetShapeDataError(f"Preset {shape_name!r} has an empty path list")
288 return tuple(paths)
289
290
291 def _parse_path_command(element: ET.Element) -> PathCommandDefinition:
292 name = _local_name(element.tag)
293 expected_points = _PATH_COMMAND_ARITY.get(name)
294 if expected_points is None:
295 raise PresetShapeDataError(f"Unsupported preset path command: {name!r}")
296 if name == "arcTo":
297 parameters = tuple(
298 _required_attribute(element, attribute)
299 for attribute in ("wR", "hR", "stAng", "swAng")
300 )
301 return PathCommandDefinition(name=name, parameters=parameters)
302 points = tuple(
303 child for child in element if _local_name(child.tag) == "pt"
304 )
305 if len(points) != expected_points:
306 raise PresetShapeDataError(
307 f"Path command {name!r} expects {expected_points} points, "
308 f"found {len(points)}"
309 )
310 parameters = tuple(
311 coordinate
312 for point in points
313 for coordinate in (
314 _required_attribute(point, "x"),
315 _required_attribute(point, "y"),
316 )
317 )
318 return PathCommandDefinition(name=name, parameters=parameters)
319
320
321 def _parse_position(parent: ET.Element) -> PointExpression:
322 positions = [
323 child for child in parent if _local_name(child.tag) == "pos"
324 ]
325 if len(positions) != 1:
326 raise PresetShapeDataError(
327 f"{_local_name(parent.tag)!r} must contain exactly one position"
328 )
329 return PointExpression(
330 x=_required_attribute(positions[0], "x"),
331 y=_required_attribute(positions[0], "y"),
332 )
333
334
335 def _find_child(parent: ET.Element, local_name: str) -> ET.Element | None:
336 matches = [child for child in parent if _local_name(child.tag) == local_name]
337 if len(matches) > 1:
338 raise PresetShapeDataError(
339 f"Preset contains duplicate {local_name!r} elements"
340 )
341 return matches[0] if matches else None
342
343
344 def _required_attribute(element: ET.Element, name: str) -> str:
345 value = element.attrib.get(name)
346 if value is None or not value.strip():
347 raise PresetShapeDataError(
348 f"Element {_local_name(element.tag)!r} requires attribute {name!r}"
349 )
350 return value.strip()
351
352
353 def _parse_boolean(value: str | None, *, default: bool) -> bool:
354 if value is None:
355 return default
356 if value in {"true", "1"}:
357 return True
358 if value in {"false", "0"}:
359 return False
360 raise PresetShapeDataError(f"Invalid DrawingML boolean value: {value!r}")
361
362
363 def _local_name(tag: str) -> str:
364 return tag.rsplit("}", 1)[-1]
365
365 lines PYTHON