| 1 | #!/usr/bin/env python3 |
| 2 | """ |
| 3 | PPT Master - Preset Shape Registry |
| 4 | |
| 5 | Expose the complete preset catalog and evaluate shape geometry instances. |
| 6 | |
| 7 | Usage: |
| 8 | Import get_preset_registry from pptx_shapes.registry. |
| 9 | |
| 10 | Examples: |
| 11 | geometry = get_preset_registry().evaluate("rightArrow", 320, 160) |
| 12 | |
| 13 | Dependencies: |
| 14 | None (only uses standard library) |
| 15 | """ |
| 16 | |
| 17 | from __future__ import annotations |
| 18 | |
| 19 | from functools import lru_cache |
| 20 | from typing import Mapping |
| 21 | |
| 22 | from .errors import ( |
| 23 | FormulaEvaluationError, |
| 24 | PresetShapeDataError, |
| 25 | UnknownPresetShapeError, |
| 26 | ) |
| 27 | from .formula import FormulaEvaluator |
| 28 | from .loader import load_preset_shape_definitions, load_shape_type_values |
| 29 | from .models import ( |
| 30 | AdjustHandleDefinition, |
| 31 | EvaluatedAdjustHandle, |
| 32 | EvaluatedConnectionSite, |
| 33 | EvaluatedPathCommand, |
| 34 | EvaluatedPoint, |
| 35 | EvaluatedPresetGeometry, |
| 36 | EvaluatedShapePath, |
| 37 | EvaluatedTextRectangle, |
| 38 | PointExpression, |
| 39 | PresetShapeDefinition, |
| 40 | immutable_mapping, |
| 41 | ) |
| 42 | |
| 43 | |
| 44 | AdjustmentValue = str | int | float |
| 45 | CONNECTOR_PRESET_TYPES = frozenset( |
| 46 | { |
| 47 | "line", |
| 48 | "lineInv", |
| 49 | "straightConnector1", |
| 50 | "bentConnector2", |
| 51 | "bentConnector3", |
| 52 | "bentConnector4", |
| 53 | "bentConnector5", |
| 54 | "curvedConnector2", |
| 55 | "curvedConnector3", |
| 56 | "curvedConnector4", |
| 57 | "curvedConnector5", |
| 58 | } |
| 59 | ) |
| 60 | |
| 61 | |
| 62 | class PresetShapeRegistry: |
| 63 | """Read-only registry for all standard DrawingML preset geometries.""" |
| 64 | |
| 65 | def __init__( |
| 66 | self, |
| 67 | definitions: tuple[PresetShapeDefinition, ...], |
| 68 | expected_names: tuple[str, ...], |
| 69 | ) -> None: |
| 70 | self._definitions = { |
| 71 | definition.name: definition for definition in definitions |
| 72 | } |
| 73 | self._names = tuple(expected_names) |
| 74 | if len(self._definitions) != len(definitions): |
| 75 | raise PresetShapeDataError("Preset registry contains duplicate names") |
| 76 | missing = sorted(set(expected_names) - set(self._definitions)) |
| 77 | extra = sorted(set(self._definitions) - set(expected_names)) |
| 78 | if missing or extra: |
| 79 | raise PresetShapeDataError( |
| 80 | "Preset registry differs from ShapeTypeValues: " |
| 81 | f"missing={missing}, extra={extra}" |
| 82 | ) |
| 83 | |
| 84 | @classmethod |
| 85 | def bundled(cls) -> PresetShapeRegistry: |
| 86 | """Load the hash-locked catalog shipped with PPT Master.""" |
| 87 | |
| 88 | names = load_shape_type_values() |
| 89 | definitions = load_preset_shape_definitions(expected_names=names) |
| 90 | return cls(definitions, names) |
| 91 | |
| 92 | @property |
| 93 | def names(self) -> tuple[str, ...]: |
| 94 | """Return all names in official ``ShapeTypeValues`` order.""" |
| 95 | |
| 96 | return self._names |
| 97 | |
| 98 | def __len__(self) -> int: |
| 99 | return len(self._definitions) |
| 100 | |
| 101 | def __contains__(self, name: object) -> bool: |
| 102 | return isinstance(name, str) and name in self._definitions |
| 103 | |
| 104 | def contains(self, name: str) -> bool: |
| 105 | """Return whether ``name`` is a standard preset in this catalog.""" |
| 106 | |
| 107 | return name in self._definitions |
| 108 | |
| 109 | def get(self, name: str) -> PresetShapeDefinition: |
| 110 | """Return one immutable source definition or raise a precise error.""" |
| 111 | |
| 112 | try: |
| 113 | return self._definitions[name] |
| 114 | except KeyError as exc: |
| 115 | raise UnknownPresetShapeError( |
| 116 | f"Unknown DrawingML preset shape: {name!r}" |
| 117 | ) from exc |
| 118 | |
| 119 | def evaluate( |
| 120 | self, |
| 121 | name: str, |
| 122 | width: float, |
| 123 | height: float, |
| 124 | *, |
| 125 | adjustments: Mapping[str, AdjustmentValue] | None = None, |
| 126 | ) -> EvaluatedPresetGeometry: |
| 127 | """Evaluate every guide, handle, connection, text rect, and path.""" |
| 128 | |
| 129 | definition = self.get(name) |
| 130 | evaluator = FormulaEvaluator(width, height) |
| 131 | supplied = dict(adjustments or {}) |
| 132 | adjustment_names = {guide.name for guide in definition.adjustments} |
| 133 | unknown = sorted(set(supplied) - adjustment_names) |
| 134 | if unknown: |
| 135 | raise FormulaEvaluationError( |
| 136 | f"Preset {name!r} has no adjustments named {unknown}" |
| 137 | ) |
| 138 | |
| 139 | evaluated_adjustments: dict[str, float] = {} |
| 140 | for guide in definition.adjustments: |
| 141 | source = supplied.get(guide.name, guide.formula) |
| 142 | value = evaluator.evaluate_value(source) |
| 143 | evaluated_adjustments[guide.name] = evaluator.bind(guide.name, value) |
| 144 | |
| 145 | evaluated_guides: dict[str, float] = {} |
| 146 | for guide in definition.guides: |
| 147 | value = evaluator.evaluate(guide.formula) |
| 148 | evaluated_guides[guide.name] = evaluator.bind(guide.name, value) |
| 149 | |
| 150 | return EvaluatedPresetGeometry( |
| 151 | name=name, |
| 152 | width=evaluator.resolve("w"), |
| 153 | height=evaluator.resolve("h"), |
| 154 | left=evaluator.resolve("l"), |
| 155 | top=evaluator.resolve("t"), |
| 156 | adjustments=immutable_mapping(evaluated_adjustments), |
| 157 | guides=immutable_mapping(evaluated_guides), |
| 158 | handles=tuple( |
| 159 | _evaluate_handle(handle, evaluator) |
| 160 | for handle in definition.handles |
| 161 | ), |
| 162 | connections=tuple( |
| 163 | EvaluatedConnectionSite( |
| 164 | angle=evaluator.evaluate_value(connection.angle), |
| 165 | position=_evaluate_point(connection.position, evaluator), |
| 166 | ) |
| 167 | for connection in definition.connections |
| 168 | ), |
| 169 | text_rectangle=_evaluate_text_rectangle(definition, evaluator), |
| 170 | paths=tuple( |
| 171 | EvaluatedShapePath( |
| 172 | coordinate_width=_path_extent( |
| 173 | path.coordinate_width, |
| 174 | evaluator.resolve("w"), |
| 175 | evaluator, |
| 176 | ), |
| 177 | coordinate_height=_path_extent( |
| 178 | path.coordinate_height, |
| 179 | evaluator.resolve("h"), |
| 180 | evaluator, |
| 181 | ), |
| 182 | fill=path.fill, |
| 183 | stroke=path.stroke, |
| 184 | extrusion_ok=path.extrusion_ok, |
| 185 | commands=tuple( |
| 186 | EvaluatedPathCommand( |
| 187 | name=command.name, |
| 188 | parameters=tuple( |
| 189 | evaluator.evaluate_value(parameter) |
| 190 | for parameter in command.parameters |
| 191 | ), |
| 192 | ) |
| 193 | for command in path.commands |
| 194 | ), |
| 195 | ) |
| 196 | for path in definition.paths |
| 197 | ), |
| 198 | ) |
| 199 | |
| 200 | |
| 201 | @lru_cache(maxsize=1) |
| 202 | def get_preset_registry() -> PresetShapeRegistry: |
| 203 | """Return the process-wide, lazily loaded bundled registry.""" |
| 204 | |
| 205 | return PresetShapeRegistry.bundled() |
| 206 | |
| 207 | |
| 208 | def _evaluate_point( |
| 209 | point: PointExpression, |
| 210 | evaluator: FormulaEvaluator, |
| 211 | ) -> EvaluatedPoint: |
| 212 | return EvaluatedPoint( |
| 213 | x=evaluator.evaluate_value(point.x), |
| 214 | y=evaluator.evaluate_value(point.y), |
| 215 | ) |
| 216 | |
| 217 | |
| 218 | def _evaluate_handle( |
| 219 | handle: AdjustHandleDefinition, |
| 220 | evaluator: FormulaEvaluator, |
| 221 | ) -> EvaluatedAdjustHandle: |
| 222 | return EvaluatedAdjustHandle( |
| 223 | kind=handle.kind, |
| 224 | position=_evaluate_point(handle.position, evaluator), |
| 225 | x_reference=handle.x_reference, |
| 226 | minimum_x=_optional_value(handle.minimum_x, evaluator), |
| 227 | maximum_x=_optional_value(handle.maximum_x, evaluator), |
| 228 | y_reference=handle.y_reference, |
| 229 | minimum_y=_optional_value(handle.minimum_y, evaluator), |
| 230 | maximum_y=_optional_value(handle.maximum_y, evaluator), |
| 231 | angle_reference=handle.angle_reference, |
| 232 | minimum_angle=_optional_value(handle.minimum_angle, evaluator), |
| 233 | maximum_angle=_optional_value(handle.maximum_angle, evaluator), |
| 234 | radius_reference=handle.radius_reference, |
| 235 | minimum_radius=_optional_value(handle.minimum_radius, evaluator), |
| 236 | maximum_radius=_optional_value(handle.maximum_radius, evaluator), |
| 237 | ) |
| 238 | |
| 239 | |
| 240 | def _evaluate_text_rectangle( |
| 241 | definition: PresetShapeDefinition, |
| 242 | evaluator: FormulaEvaluator, |
| 243 | ) -> EvaluatedTextRectangle | None: |
| 244 | rectangle = definition.text_rectangle |
| 245 | if rectangle is None: |
| 246 | return None |
| 247 | return EvaluatedTextRectangle( |
| 248 | left=evaluator.evaluate_value(rectangle.left), |
| 249 | top=evaluator.evaluate_value(rectangle.top), |
| 250 | right=evaluator.evaluate_value(rectangle.right), |
| 251 | bottom=evaluator.evaluate_value(rectangle.bottom), |
| 252 | ) |
| 253 | |
| 254 | |
| 255 | def _optional_value( |
| 256 | value: str | None, |
| 257 | evaluator: FormulaEvaluator, |
| 258 | ) -> float | None: |
| 259 | return None if value is None else evaluator.evaluate_value(value) |
| 260 | |
| 261 | |
| 262 | def _path_extent( |
| 263 | value: str | None, |
| 264 | shape_extent: float, |
| 265 | evaluator: FormulaEvaluator, |
| 266 | ) -> float: |
| 267 | if value is None: |
| 268 | return shape_extent |
| 269 | resolved = evaluator.evaluate_value(value) |
| 270 | return shape_extent if resolved == 0 else resolved |
| 271 |