返回 ppt-master
formula.py
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
337 lines PYTHON