| 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 |