#!/usr/bin/env python3 """ PPT Master - DrawingML Formula Evaluator Evaluate DrawingML geometry guide formulas and standard built-in guides. Usage: Import FormulaEvaluator or evaluate_formula from pptx_shapes.formula. Examples: evaluator = FormulaEvaluator(width=200, height=100) evaluator.evaluate("*/ w 1 2") Dependencies: None (only uses standard library) """ from __future__ import annotations import math import re from types import MappingProxyType from typing import Mapping from .errors import ( FormulaEvaluationError, FormulaSyntaxError, UnknownGuideError, ) OOXML_DEGREE = 60000.0 FULL_CIRCLE = 360.0 * OOXML_DEGREE OOXML_COORDINATE_MIN = -27273042329600 OOXML_COORDINATE_MAX = 27273042316900 OOXML_LINE_WIDTH_MAX = 20116800 _OPERATOR_ARITY = { "val": 1, "*/": 3, "+-": 3, "+/": 3, "?:": 3, "abs": 1, "at2": 2, "cat2": 3, "cos": 2, "max": 2, "min": 2, "mod": 3, "pin": 3, "sat2": 3, "sin": 2, "sqrt": 1, "tan": 2, } SUPPORTED_OPERATORS = frozenset(_OPERATOR_ARITY) _DIMENSION_GUIDE_RE = re.compile(r"^(wd|hd|ssd)([1-9][0-9]*)$") _ANGLE_GUIDE_RE = re.compile(r"^(?:(\d+))?cd([1-9][0-9]*)$") def validate_ooxml_xfrm( off_x: int, off_y: int, ext_cx: int, ext_cy: int, ) -> None: """Validate DrawingML shape offsets and extents in EMU.""" for name, value in (("x", off_x), ("y", off_y)): if not OOXML_COORDINATE_MIN <= value <= OOXML_COORDINATE_MAX: raise ValueError( f"DrawingML xfrm offset {name}={value} is outside the " "OOXML coordinate range" ) for name, value in (("cx", ext_cx), ("cy", ext_cy)): if value < 0 or value > OOXML_COORDINATE_MAX: raise ValueError( f"DrawingML xfrm extent {name}={value} is outside the " "OOXML positive-coordinate range" ) def validate_ooxml_line_width(width: int) -> None: """Validate one DrawingML ``ST_LineWidth`` value in EMU.""" if width < 0 or width > OOXML_LINE_WIDTH_MAX: raise ValueError( f"DrawingML line width {width} is outside the OOXML line-width range" ) def build_builtin_guides( width: float, height: float, *, left: float = 0.0, top: float = 0.0, ) -> Mapping[str, float]: """Build the standard DrawingML geometry guide context for one frame.""" width = _finite_number(width, "width") height = _finite_number(height, "height") left = _finite_number(left, "left") top = _finite_number(top, "top") if width < 0 or height < 0: raise FormulaEvaluationError("Shape width and height must be non-negative") right = left + width bottom = top + height short_side = min(width, height) long_side = max(width, height) values = { "l": left, "t": top, "r": right, "b": bottom, "w": width, "h": height, "hc": left + width / 2.0, "vc": top + height / 2.0, "ss": short_side, "ls": long_side, "cd2": FULL_CIRCLE / 2.0, "cd3": FULL_CIRCLE / 3.0, "cd4": FULL_CIRCLE / 4.0, "cd8": FULL_CIRCLE / 8.0, "3cd4": FULL_CIRCLE * 3.0 / 4.0, "3cd8": FULL_CIRCLE * 3.0 / 8.0, "5cd8": FULL_CIRCLE * 5.0 / 8.0, "7cd8": FULL_CIRCLE * 7.0 / 8.0, } for divisor in (2, 3, 4, 5, 6, 8, 10, 12, 16, 32): values[f"wd{divisor}"] = width / divisor values[f"hd{divisor}"] = height / divisor values[f"ssd{divisor}"] = short_side / divisor return MappingProxyType(values) class FormulaEvaluator: """Evaluate guide formulas against a shape-local DrawingML context.""" def __init__( self, width: float, height: float, *, left: float = 0.0, top: float = 0.0, values: Mapping[str, float] | None = None, ) -> None: self._builtins = dict( build_builtin_guides(width, height, left=left, top=top) ) self._values: dict[str, float] = {} if values: for name, value in values.items(): self.bind(name, value) @property def values(self) -> Mapping[str, float]: """Return a detached, read-only view of explicitly bound guides.""" return MappingProxyType(dict(self._values)) @property def builtins(self) -> Mapping[str, float]: """Return the resolved built-in guide values.""" return MappingProxyType(dict(self._builtins)) def bind(self, name: str, value: float) -> float: """Bind one evaluated adjustment or guide and return its numeric value.""" if not name or name.isspace(): raise FormulaEvaluationError("Guide name must not be empty") number = _finite_number(value, f"guide {name!r}") self._values[name] = number return number def resolve(self, token: str) -> float: """Resolve a literal, bound guide, or DrawingML built-in guide token.""" token = token.strip() if not token: raise UnknownGuideError("Guide token must not be empty") try: return _finite_number(float(token), f"literal {token!r}") except ValueError: pass if token in self._values: return self._values[token] if token in self._builtins: return self._builtins[token] dynamic = self._resolve_dynamic_builtin(token) if dynamic is not None: self._builtins[token] = dynamic return dynamic raise UnknownGuideError(f"Unknown DrawingML guide token: {token!r}") def evaluate(self, formula: str) -> float: """Evaluate one complete DrawingML geometry formula.""" parts = formula.split() if not parts: raise FormulaSyntaxError("DrawingML formula must not be empty") operator = parts[0] expected = _OPERATOR_ARITY.get(operator) if expected is None: raise FormulaSyntaxError( f"Unsupported DrawingML formula operator: {operator!r}" ) if len(parts) < expected + 1: raise FormulaSyntaxError( f"Operator {operator!r} expects {expected} operands, " f"received {len(parts) - 1}: {formula!r}" ) trailing = parts[expected + 1 :] if trailing and not ( operator == "+-" and all(token == "0" for token in trailing) ): raise FormulaSyntaxError( f"Operator {operator!r} expects {expected} operands, " f"received {len(parts) - 1}: {formula!r}" ) # POI 5.4.1 carries three circular-arrow formulas with one inert # trailing zero ("+- xH 0 dxB 0"). Apache POI ignores surplus tokens; # accept only this harmless form while keeping all other arity errors. operands = tuple(self.resolve(token) for token in parts[1 : expected + 1]) try: result = _apply_operator(operator, operands) except (OverflowError, ValueError) as exc: raise FormulaEvaluationError( f"Cannot evaluate DrawingML formula {formula!r}: {exc}" ) from exc return _finite_number(result, f"result of {formula!r}") def evaluate_value(self, value: str | int | float) -> float: """Evaluate a formula string, guide token, or numeric adjustment value.""" if isinstance(value, str): parts = value.split() if not parts: raise FormulaSyntaxError("DrawingML value must not be empty") if parts[0] in SUPPORTED_OPERATORS: return self.evaluate(value) if len(parts) == 1: return self.resolve(parts[0]) raise FormulaSyntaxError(f"Invalid DrawingML value: {value!r}") return _finite_number(value, "guide value") def _resolve_dynamic_builtin(self, token: str) -> float | None: dimension_match = _DIMENSION_GUIDE_RE.fullmatch(token) if dimension_match: family, divisor_text = dimension_match.groups() divisor = int(divisor_text) base_name = {"wd": "w", "hd": "h", "ssd": "ss"}[family] return self._builtins[base_name] / divisor angle_match = _ANGLE_GUIDE_RE.fullmatch(token) if angle_match: numerator_text, divisor_text = angle_match.groups() numerator = int(numerator_text or "1") divisor = int(divisor_text) return FULL_CIRCLE * numerator / divisor return None def evaluate_formula( formula: str, *, width: float, height: float, left: float = 0.0, top: float = 0.0, values: Mapping[str, float] | None = None, ) -> float: """Evaluate one formula without manually constructing an evaluator.""" evaluator = FormulaEvaluator( width, height, left=left, top=top, values=values, ) return evaluator.evaluate(formula) def _apply_operator(operator: str, values: tuple[float, ...]) -> float: if operator == "val": return values[0] if operator == "*/": return 0.0 if values[2] == 0 else values[0] * values[1] / values[2] if operator == "+-": return values[0] + values[1] - values[2] if operator == "+/": return 0.0 if values[2] == 0 else (values[0] + values[1]) / values[2] if operator == "?:": return values[1] if values[0] > 0 else values[2] if operator == "abs": return abs(values[0]) if operator == "at2": return math.degrees(math.atan2(values[1], values[0])) * OOXML_DEGREE if operator == "cat2": return values[0] * math.cos(math.atan2(values[2], values[1])) if operator == "cos": return values[0] * math.cos(math.radians(values[1] / OOXML_DEGREE)) if operator == "max": return max(values[0], values[1]) if operator == "min": return min(values[0], values[1]) if operator == "mod": return math.sqrt(sum(value * value for value in values)) if operator == "pin": return max(values[0], min(values[1], values[2])) if operator == "sat2": return values[0] * math.sin(math.atan2(values[2], values[1])) if operator == "sin": return values[0] * math.sin(math.radians(values[1] / OOXML_DEGREE)) if operator == "sqrt": return math.sqrt(values[0]) if operator == "tan": return values[0] * math.tan(math.radians(values[1] / OOXML_DEGREE)) raise FormulaSyntaxError(f"Unsupported DrawingML formula operator: {operator!r}") def _finite_number(value: float, label: str) -> float: try: number = float(value) except (TypeError, ValueError) as exc: raise FormulaEvaluationError(f"{label} must be numeric") from exc if not math.isfinite(number): raise FormulaEvaluationError(f"{label} must be finite") return number