| 1 | #!/usr/bin/env python3 |
| 2 | """ |
| 3 | PPT Master - DrawingML Formula Evaluator |
| 4 | |
| 5 | Evaluate DrawingML geometry guide formulas and standard built-in guides. |
| 6 | |
| 7 | Usage: |
| 8 | Import FormulaEvaluator or evaluate_formula from pptx_shapes.formula. |
| 9 | |
| 10 | Examples: |
| 11 | evaluator = FormulaEvaluator(width=200, height=100) |
| 12 | evaluator.evaluate("*/ w 1 2") |
| 13 | |
| 14 | Dependencies: |
| 15 | None (only uses standard library) |
| 16 | """ |
| 17 | |
| 18 | from __future__ import annotations |
| 19 | |
| 20 | import math |
| 21 | import re |
| 22 | from types import MappingProxyType |
| 23 | from typing import Mapping |
| 24 | |
| 25 | from .errors import ( |
| 26 | FormulaEvaluationError, |
| 27 | FormulaSyntaxError, |
| 28 | UnknownGuideError, |
| 29 | ) |
| 30 | |
| 31 | |
| 32 | OOXML_DEGREE = 60000.0 |
| 33 | FULL_CIRCLE = 360.0 * OOXML_DEGREE |
| 34 | OOXML_COORDINATE_MIN = -27273042329600 |
| 35 | OOXML_COORDINATE_MAX = 27273042316900 |
| 36 | OOXML_LINE_WIDTH_MAX = 20116800 |
| 37 | |
| 38 | _OPERATOR_ARITY = { |
| 39 | "val": 1, |
| 40 | "*/": 3, |
| 41 | "+-": 3, |
| 42 | "+/": 3, |
| 43 | "?:": 3, |
| 44 | "abs": 1, |
| 45 | "at2": 2, |
| 46 | "cat2": 3, |
| 47 | "cos": 2, |
| 48 | "max": 2, |
| 49 | "min": 2, |
| 50 | "mod": 3, |
| 51 | "pin": 3, |
| 52 | "sat2": 3, |
| 53 | "sin": 2, |
| 54 | "sqrt": 1, |
| 55 | "tan": 2, |
| 56 | } |
| 57 | SUPPORTED_OPERATORS = frozenset(_OPERATOR_ARITY) |
| 58 | |
| 59 | _DIMENSION_GUIDE_RE = re.compile(r"^(wd|hd|ssd)([1-9][0-9]*)$") |
| 60 | _ANGLE_GUIDE_RE = re.compile(r"^(?:(\d+))?cd([1-9][0-9]*)$") |
| 61 | |
| 62 | |
| 63 | def validate_ooxml_xfrm( |
| 64 | off_x: int, |
| 65 | off_y: int, |
| 66 | ext_cx: int, |
| 67 | ext_cy: int, |
| 68 | ) -> None: |
| 69 | """Validate DrawingML shape offsets and extents in EMU.""" |
| 70 | for name, value in (("x", off_x), ("y", off_y)): |
| 71 | if not OOXML_COORDINATE_MIN <= value <= OOXML_COORDINATE_MAX: |
| 72 | raise ValueError( |
| 73 | f"DrawingML xfrm offset {name}={value} is outside the " |
| 74 | "OOXML coordinate range" |
| 75 | ) |
| 76 | for name, value in (("cx", ext_cx), ("cy", ext_cy)): |
| 77 | if value < 0 or value > OOXML_COORDINATE_MAX: |
| 78 | raise ValueError( |
| 79 | f"DrawingML xfrm extent {name}={value} is outside the " |
| 80 | "OOXML positive-coordinate range" |
| 81 | ) |
| 82 | |
| 83 | |
| 84 | def validate_ooxml_line_width(width: int) -> None: |
| 85 | """Validate one DrawingML ``ST_LineWidth`` value in EMU.""" |
| 86 | if width < 0 or width > OOXML_LINE_WIDTH_MAX: |
| 87 | raise ValueError( |
| 88 | f"DrawingML line width {width} is outside the OOXML line-width range" |
| 89 | ) |
| 90 | |
| 91 | |
| 92 | def build_builtin_guides( |
| 93 | width: float, |
| 94 | height: float, |
| 95 | *, |
| 96 | left: float = 0.0, |
| 97 | top: float = 0.0, |
| 98 | ) -> Mapping[str, float]: |
| 99 | """Build the standard DrawingML geometry guide context for one frame.""" |
| 100 | |
| 101 | width = _finite_number(width, "width") |
| 102 | height = _finite_number(height, "height") |
| 103 | left = _finite_number(left, "left") |
| 104 | top = _finite_number(top, "top") |
| 105 | if width < 0 or height < 0: |
| 106 | raise FormulaEvaluationError("Shape width and height must be non-negative") |
| 107 | |
| 108 | right = left + width |
| 109 | bottom = top + height |
| 110 | short_side = min(width, height) |
| 111 | long_side = max(width, height) |
| 112 | values = { |
| 113 | "l": left, |
| 114 | "t": top, |
| 115 | "r": right, |
| 116 | "b": bottom, |
| 117 | "w": width, |
| 118 | "h": height, |
| 119 | "hc": left + width / 2.0, |
| 120 | "vc": top + height / 2.0, |
| 121 | "ss": short_side, |
| 122 | "ls": long_side, |
| 123 | "cd2": FULL_CIRCLE / 2.0, |
| 124 | "cd3": FULL_CIRCLE / 3.0, |
| 125 | "cd4": FULL_CIRCLE / 4.0, |
| 126 | "cd8": FULL_CIRCLE / 8.0, |
| 127 | "3cd4": FULL_CIRCLE * 3.0 / 4.0, |
| 128 | "3cd8": FULL_CIRCLE * 3.0 / 8.0, |
| 129 | "5cd8": FULL_CIRCLE * 5.0 / 8.0, |
| 130 | "7cd8": FULL_CIRCLE * 7.0 / 8.0, |
| 131 | } |
| 132 | for divisor in (2, 3, 4, 5, 6, 8, 10, 12, 16, 32): |
| 133 | values[f"wd{divisor}"] = width / divisor |
| 134 | values[f"hd{divisor}"] = height / divisor |
| 135 | values[f"ssd{divisor}"] = short_side / divisor |
| 136 | return MappingProxyType(values) |
| 137 | |
| 138 | |
| 139 | class FormulaEvaluator: |
| 140 | """Evaluate guide formulas against a shape-local DrawingML context.""" |
| 141 | |
| 142 | def __init__( |
| 143 | self, |
| 144 | width: float, |
| 145 | height: float, |
| 146 | *, |
| 147 | left: float = 0.0, |
| 148 | top: float = 0.0, |
| 149 | values: Mapping[str, float] | None = None, |
| 150 | ) -> None: |
| 151 | self._builtins = dict( |
| 152 | build_builtin_guides(width, height, left=left, top=top) |
| 153 | ) |
| 154 | self._values: dict[str, float] = {} |
| 155 | if values: |
| 156 | for name, value in values.items(): |
| 157 | self.bind(name, value) |
| 158 | |
| 159 | @property |
| 160 | def values(self) -> Mapping[str, float]: |
| 161 | """Return a detached, read-only view of explicitly bound guides.""" |
| 162 | |
| 163 | return MappingProxyType(dict(self._values)) |
| 164 | |
| 165 | @property |
| 166 | def builtins(self) -> Mapping[str, float]: |
| 167 | """Return the resolved built-in guide values.""" |
| 168 | |
| 169 | return MappingProxyType(dict(self._builtins)) |
| 170 | |
| 171 | def bind(self, name: str, value: float) -> float: |
| 172 | """Bind one evaluated adjustment or guide and return its numeric value.""" |
| 173 | |
| 174 | if not name or name.isspace(): |
| 175 | raise FormulaEvaluationError("Guide name must not be empty") |
| 176 | number = _finite_number(value, f"guide {name!r}") |
| 177 | self._values[name] = number |
| 178 | return number |
| 179 | |
| 180 | def resolve(self, token: str) -> float: |
| 181 | """Resolve a literal, bound guide, or DrawingML built-in guide token.""" |
| 182 | |
| 183 | token = token.strip() |
| 184 | if not token: |
| 185 | raise UnknownGuideError("Guide token must not be empty") |
| 186 | try: |
| 187 | return _finite_number(float(token), f"literal {token!r}") |
| 188 | except ValueError: |
| 189 | pass |
| 190 | |
| 191 | if token in self._values: |
| 192 | return self._values[token] |
| 193 | if token in self._builtins: |
| 194 | return self._builtins[token] |
| 195 | |
| 196 | dynamic = self._resolve_dynamic_builtin(token) |
| 197 | if dynamic is not None: |
| 198 | self._builtins[token] = dynamic |
| 199 | return dynamic |
| 200 | raise UnknownGuideError(f"Unknown DrawingML guide token: {token!r}") |
| 201 | |
| 202 | def evaluate(self, formula: str) -> float: |
| 203 | """Evaluate one complete DrawingML geometry formula.""" |
| 204 | |
| 205 | parts = formula.split() |
| 206 | if not parts: |
| 207 | raise FormulaSyntaxError("DrawingML formula must not be empty") |
| 208 | operator = parts[0] |
| 209 | expected = _OPERATOR_ARITY.get(operator) |
| 210 | if expected is None: |
| 211 | raise FormulaSyntaxError( |
| 212 | f"Unsupported DrawingML formula operator: {operator!r}" |
| 213 | ) |
| 214 | if len(parts) < expected + 1: |
| 215 | raise FormulaSyntaxError( |
| 216 | f"Operator {operator!r} expects {expected} operands, " |
| 217 | f"received {len(parts) - 1}: {formula!r}" |
| 218 | ) |
| 219 | trailing = parts[expected + 1 :] |
| 220 | if trailing and not ( |
| 221 | operator == "+-" and all(token == "0" for token in trailing) |
| 222 | ): |
| 223 | raise FormulaSyntaxError( |
| 224 | f"Operator {operator!r} expects {expected} operands, " |
| 225 | f"received {len(parts) - 1}: {formula!r}" |
| 226 | ) |
| 227 | # POI 5.4.1 carries three circular-arrow formulas with one inert |
| 228 | # trailing zero ("+- xH 0 dxB 0"). Apache POI ignores surplus tokens; |
| 229 | # accept only this harmless form while keeping all other arity errors. |
| 230 | operands = tuple(self.resolve(token) for token in parts[1 : expected + 1]) |
| 231 | try: |
| 232 | result = _apply_operator(operator, operands) |
| 233 | except (OverflowError, ValueError) as exc: |
| 234 | raise FormulaEvaluationError( |
| 235 | f"Cannot evaluate DrawingML formula {formula!r}: {exc}" |
| 236 | ) from exc |
| 237 | return _finite_number(result, f"result of {formula!r}") |
| 238 | |
| 239 | def evaluate_value(self, value: str | int | float) -> float: |
| 240 | """Evaluate a formula string, guide token, or numeric adjustment value.""" |
| 241 | |
| 242 | if isinstance(value, str): |
| 243 | parts = value.split() |
| 244 | if not parts: |
| 245 | raise FormulaSyntaxError("DrawingML value must not be empty") |
| 246 | if parts[0] in SUPPORTED_OPERATORS: |
| 247 | return self.evaluate(value) |
| 248 | if len(parts) == 1: |
| 249 | return self.resolve(parts[0]) |
| 250 | raise FormulaSyntaxError(f"Invalid DrawingML value: {value!r}") |
| 251 | return _finite_number(value, "guide value") |
| 252 | |
| 253 | def _resolve_dynamic_builtin(self, token: str) -> float | None: |
| 254 | dimension_match = _DIMENSION_GUIDE_RE.fullmatch(token) |
| 255 | if dimension_match: |
| 256 | family, divisor_text = dimension_match.groups() |
| 257 | divisor = int(divisor_text) |
| 258 | base_name = {"wd": "w", "hd": "h", "ssd": "ss"}[family] |
| 259 | return self._builtins[base_name] / divisor |
| 260 | |
| 261 | angle_match = _ANGLE_GUIDE_RE.fullmatch(token) |
| 262 | if angle_match: |
| 263 | numerator_text, divisor_text = angle_match.groups() |
| 264 | numerator = int(numerator_text or "1") |
| 265 | divisor = int(divisor_text) |
| 266 | return FULL_CIRCLE * numerator / divisor |
| 267 | return None |
| 268 | |
| 269 | |
| 270 | def evaluate_formula( |
| 271 | formula: str, |
| 272 | *, |
| 273 | width: float, |
| 274 | height: float, |
| 275 | left: float = 0.0, |
| 276 | top: float = 0.0, |
| 277 | values: Mapping[str, float] | None = None, |
| 278 | ) -> float: |
| 279 | """Evaluate one formula without manually constructing an evaluator.""" |
| 280 | |
| 281 | evaluator = FormulaEvaluator( |
| 282 | width, |
| 283 | height, |
| 284 | left=left, |
| 285 | top=top, |
| 286 | values=values, |
| 287 | ) |
| 288 | return evaluator.evaluate(formula) |
| 289 | |
| 290 | |
| 291 | def _apply_operator(operator: str, values: tuple[float, ...]) -> float: |
| 292 | if operator == "val": |
| 293 | return values[0] |
| 294 | if operator == "*/": |
| 295 | return 0.0 if values[2] == 0 else values[0] * values[1] / values[2] |
| 296 | if operator == "+-": |
| 297 | return values[0] + values[1] - values[2] |
| 298 | if operator == "+/": |
| 299 | return 0.0 if values[2] == 0 else (values[0] + values[1]) / values[2] |
| 300 | if operator == "?:": |
| 301 | return values[1] if values[0] > 0 else values[2] |
| 302 | if operator == "abs": |
| 303 | return abs(values[0]) |
| 304 | if operator == "at2": |
| 305 | return math.degrees(math.atan2(values[1], values[0])) * OOXML_DEGREE |
| 306 | if operator == "cat2": |
| 307 | return values[0] * math.cos(math.atan2(values[2], values[1])) |
| 308 | if operator == "cos": |
| 309 | return values[0] * math.cos(math.radians(values[1] / OOXML_DEGREE)) |
| 310 | if operator == "max": |
| 311 | return max(values[0], values[1]) |
| 312 | if operator == "min": |
| 313 | return min(values[0], values[1]) |
| 314 | if operator == "mod": |
| 315 | return math.sqrt(sum(value * value for value in values)) |
| 316 | if operator == "pin": |
| 317 | return max(values[0], min(values[1], values[2])) |
| 318 | if operator == "sat2": |
| 319 | return values[0] * math.sin(math.atan2(values[2], values[1])) |
| 320 | if operator == "sin": |
| 321 | return values[0] * math.sin(math.radians(values[1] / OOXML_DEGREE)) |
| 322 | if operator == "sqrt": |
| 323 | return math.sqrt(values[0]) |
| 324 | if operator == "tan": |
| 325 | return values[0] * math.tan(math.radians(values[1] / OOXML_DEGREE)) |
| 326 | raise FormulaSyntaxError(f"Unsupported DrawingML formula operator: {operator!r}") |
| 327 | |
| 328 | |
| 329 | def _finite_number(value: float, label: str) -> float: |
| 330 | try: |
| 331 | number = float(value) |
| 332 | except (TypeError, ValueError) as exc: |
| 333 | raise FormulaEvaluationError(f"{label} must be numeric") from exc |
| 334 | if not math.isfinite(number): |
| 335 | raise FormulaEvaluationError(f"{label} must be finite") |
| 336 | return number |
| 337 |