返回 ppt-master
formula_omml.py
1 #!/usr/bin/env python3
2 """
3 PPT Master - Native Formula OMML Emitter
4
5 Emit the Microsoft 365 LaTeX profile AST as editable Office Math XML and
6 validate the narrow XML vocabulary produced by this module.
7
8 See references/native-formula.md for the owning compatibility contract.
9
10 Usage:
11 Imported by formula_compiler.py.
12
13 Examples:
14 emit_omml(expression, display=True)
15
16 Dependencies:
17 None (only uses standard library and local PPT Master modules)
18 """
19
20 from __future__ import annotations
21
22 import re
23 from xml.etree import ElementTree as ET
24
25 from .formula_ast import (
26 Accent,
27 AlignmentPoint,
28 Bar,
29 BorderBox,
30 Delimiter,
31 EquationArray,
32 Fraction,
33 Function,
34 GroupChar,
35 Limit,
36 Matrix,
37 Nary,
38 Node,
39 OperatorEmulator,
40 Phantom,
41 Prescript,
42 Radical,
43 RunStyle,
44 Script,
45 Sequence,
46 Styled,
47 Text,
48 merge_run_styles,
49 )
50 from .formula_parser import FormulaCompileError
51
52
53 MATH_NS = "http://schemas.openxmlformats.org/officeDocument/2006/math"
54 DRAWING_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
55 XML_NS = "http://www.w3.org/XML/1998/namespace"
56
57 _MAX_OMML_LENGTH = 1_048_576
58 _MAX_OMML_DEPTH = 256
59 _FORBIDDEN_XML_RE = re.compile(r"<!\s*(?:DOCTYPE|ENTITY)\b", re.IGNORECASE)
60
61 _MATH_ELEMENTS = frozenset({
62 "acc", "accPr", "bar", "barPr", "baseJc", "begChr", "box", "boxPr",
63 "borderBox", "borderBoxPr", "chr", "count", "ctrlPr", "d", "deg", "degHide",
64 "den", "dPr", "e", "endChr", "eqArr", "eqArrPr", "f", "fName",
65 "fPr", "func", "funcPr", "groupChr", "groupChrPr", "grow", "hideBot",
66 "hideLeft", "hideRight", "hideTop", "jc", "lim", "limLoc", "limLow",
67 "limLowPr", "limUpp", "limUppPr", "lit", "m", "mc", "mcPr", "mcs",
68 "mPr", "mr", "nary", "naryPr", "nor", "num", "oMath", "oMathPara",
69 "oMathParaPr", "phant", "phantPr", "plcHide", "pos", "r", "rad",
70 "radPr", "rPr", "scr", "sepChr", "show", "sPre", "sPrePr", "opEmu",
71 "sSub", "sSubSup", "sSup", "strikeBLTR", "strikeTLBR",
72 "sty", "sub", "subHide", "sup", "supHide", "t",
73 "type", "vertJc", "zeroAsc", "zeroDesc", "zeroWid",
74 })
75 _DRAWING_ELEMENTS = frozenset({
76 "cs", "ea", "latin", "rPr", "solidFill", "srgbClr",
77 })
78 _DRAWING_ATTRIBUTES = frozenset({
79 "b", "dirty", "i", "lang", "sz", "typeface", "val",
80 })
81 _ARGUMENT_ELEMENTS = frozenset({
82 "acc", "bar", "borderBox", "box", "d", "eqArr", "f", "func", "groupChr",
83 "limLow", "limUpp", "m", "nary", "phant", "r", "rad", "sPre",
84 "sSub", "sSubSup", "sSup",
85 })
86 _ARGUMENT_CONTAINERS = frozenset({
87 "deg", "den", "e", "fName", "lim", "num", "oMath", "sub", "sup",
88 })
89 _ON_OFF_ELEMENTS = frozenset({
90 "degHide", "grow", "hideBot", "hideLeft", "hideRight", "hideTop", "nor",
91 "lit", "opEmu", "plcHide", "show", "strikeBLTR", "strikeTLBR", "subHide", "supHide",
92 "zeroAsc", "zeroDesc", "zeroWid",
93 })
94 _VALUE_ELEMENTS = frozenset({
95 "baseJc", "begChr", "chr", "count", "degHide", "endChr", "grow",
96 "hideBot", "hideLeft", "hideRight", "hideTop", "jc", "limLoc",
97 "lit", "nor", "opEmu", "plcHide", "pos", "scr", "sepChr", "show", "strikeBLTR",
98 "strikeTLBR", "sty", "subHide", "supHide", "type", "vertJc", "zeroAsc",
99 "zeroDesc", "zeroWid",
100 })
101 _EMPTY_PROPERTY_ELEMENTS = frozenset({
102 "funcPr", "limLowPr", "limUppPr", "sPrePr",
103 })
104 _PROPERTY_ORDER = {
105 "accPr": ("chr", "ctrlPr"),
106 "barPr": ("pos", "ctrlPr"),
107 "boxPr": ("opEmu", "ctrlPr"),
108 "borderBoxPr": (
109 "hideTop", "hideBot", "hideLeft", "hideRight", "strikeBLTR",
110 "strikeTLBR", "ctrlPr",
111 ),
112 "dPr": ("begChr", "sepChr", "endChr", "grow", "ctrlPr"),
113 "eqArrPr": ("baseJc",),
114 "fPr": ("type", "ctrlPr"),
115 "groupChrPr": ("chr", "pos", "vertJc", "ctrlPr"),
116 "mPr": ("baseJc", "plcHide", "mcs"),
117 "mcPr": ("count",),
118 "naryPr": (
119 "chr", "limLoc", "grow", "subHide", "supHide", "ctrlPr",
120 ),
121 "oMathParaPr": ("jc",),
122 "phantPr": ("show", "zeroWid", "zeroAsc", "zeroDesc"),
123 "radPr": ("degHide", "ctrlPr"),
124 "rPr": ("lit", "nor", "scr", "sty"),
125 }
126
127
128 def _math_tag(local_name: str) -> str:
129 return f"{{{MATH_NS}}}{local_name}"
130
131
132 def _math_attrs(**values: str) -> dict[str, str]:
133 return {_math_tag(name): value for name, value in values.items()}
134
135
136 def _math_element(
137 parent: ET.Element,
138 local_name: str,
139 **attributes: str,
140 ) -> ET.Element:
141 return ET.SubElement(parent, _math_tag(local_name), _math_attrs(**attributes))
142
143
144 def _effective_style(
145 inherited: RunStyle | None,
146 local: RunStyle | None,
147 ) -> RunStyle | None:
148 return merge_run_styles(inherited, local)
149
150
151 def _append_control_style(
152 properties: ET.Element,
153 style: RunStyle | None,
154 ) -> None:
155 """Persist local control-glyph style that cannot live on a math run."""
156 if style is None:
157 return
158 drawing_attributes: dict[str, str] = {}
159 if style.style == "bi":
160 drawing_attributes.update({"b": "1", "i": "1"})
161 if not style.color and not drawing_attributes:
162 return
163 control_properties = _math_element(properties, "ctrlPr")
164 drawing_properties = ET.SubElement(
165 control_properties,
166 f"{{{DRAWING_NS}}}rPr",
167 drawing_attributes,
168 )
169 if style.color:
170 solid_fill = ET.SubElement(
171 drawing_properties,
172 f"{{{DRAWING_NS}}}solidFill",
173 )
174 ET.SubElement(
175 solid_fill,
176 f"{{{DRAWING_NS}}}srgbClr",
177 {"val": style.color},
178 )
179
180
181 def _append_run(
182 parent: ET.Element,
183 value: str,
184 style: RunStyle | None,
185 *,
186 literal: bool = False,
187 ) -> None:
188 if not value:
189 return
190 if style is not None and style.script == "script" and any(
191 not character.isupper() for character in value
192 ):
193 start = 0
194 uppercase = value[0].isupper()
195 for index in range(1, len(value) + 1):
196 current = value[index].isupper() if index < len(value) else not uppercase
197 if current == uppercase:
198 continue
199 segment_style = style if uppercase else RunStyle(
200 normal=style.normal,
201 color=style.color,
202 bold=style.bold,
203 italic=style.italic,
204 typeface=style.typeface,
205 )
206 _append_run(
207 parent,
208 value[start:index],
209 segment_style,
210 literal=literal,
211 )
212 start = index
213 uppercase = current
214 return
215 run = _math_element(parent, "r")
216 if literal or (
217 style is not None and (style.style or style.normal or style.script)
218 ):
219 properties = _math_element(run, "rPr")
220 if literal:
221 _math_element(properties, "lit", val="on")
222 if style is not None and style.normal is True:
223 _math_element(properties, "nor", val="on")
224 elif style is not None:
225 if style.script:
226 _math_element(properties, "scr", val=style.script)
227 if style.style:
228 _math_element(properties, "sty", val=style.style)
229 if style is not None and any(
230 value is not None
231 for value in (style.color, style.bold, style.italic, style.typeface)
232 ):
233 drawing_attributes: dict[str, str] = {}
234 if style.bold is not None:
235 drawing_attributes["b"] = "1" if style.bold else "0"
236 if style.italic is not None:
237 drawing_attributes["i"] = "1" if style.italic else "0"
238 drawing_properties = ET.SubElement(
239 run,
240 f"{{{DRAWING_NS}}}rPr",
241 drawing_attributes,
242 )
243 if style.color:
244 solid_fill = ET.SubElement(
245 drawing_properties,
246 f"{{{DRAWING_NS}}}solidFill",
247 )
248 ET.SubElement(
249 solid_fill,
250 f"{{{DRAWING_NS}}}srgbClr",
251 {"val": style.color},
252 )
253 if style.typeface:
254 for local_name in ("latin", "ea", "cs"):
255 ET.SubElement(
256 drawing_properties,
257 f"{{{DRAWING_NS}}}{local_name}",
258 {"typeface": style.typeface},
259 )
260 text = _math_element(run, "t")
261 if value != value.strip() or " " in value:
262 text.set(f"{{{XML_NS}}}space", "preserve")
263 text.text = value
264
265
266 def _append_sequence(
267 parent: ET.Element,
268 sequence: Sequence,
269 inherited_style: RunStyle | None,
270 *,
271 display: bool,
272 ) -> None:
273 for child in sequence.children:
274 _append_node(parent, child, inherited_style, display=display)
275
276
277 def _append_node(
278 parent: ET.Element,
279 node: Node,
280 inherited_style: RunStyle | None = None,
281 *,
282 display: bool,
283 ) -> None:
284 if isinstance(node, Text):
285 _append_run(
286 parent,
287 node.value,
288 _effective_style(inherited_style, node.style),
289 literal=node.literal,
290 )
291 return
292 if isinstance(node, Sequence):
293 _append_sequence(parent, node, inherited_style, display=display)
294 return
295 if isinstance(node, Styled):
296 _append_sequence(
297 parent,
298 node.body,
299 _effective_style(inherited_style, node.style),
300 display=display,
301 )
302 return
303 if isinstance(node, Fraction):
304 fraction = _math_element(parent, "f")
305 properties = _math_element(fraction, "fPr")
306 _math_element(properties, "type", val=node.kind)
307 _append_control_style(properties, inherited_style)
308 numerator = _math_element(fraction, "num")
309 _append_sequence(numerator, node.numerator, inherited_style, display=display)
310 denominator = _math_element(fraction, "den")
311 _append_sequence(denominator, node.denominator, inherited_style, display=display)
312 return
313 if isinstance(node, Radical):
314 radical = _math_element(parent, "rad")
315 properties = _math_element(radical, "radPr")
316 _math_element(properties, "degHide", val="off" if node.degree else "on")
317 _append_control_style(properties, inherited_style)
318 degree = _math_element(radical, "deg")
319 if node.degree is not None:
320 _append_sequence(degree, node.degree, inherited_style, display=display)
321 body = _math_element(radical, "e")
322 _append_sequence(body, node.body, inherited_style, display=display)
323 return
324 if isinstance(node, Script):
325 _append_script(parent, node, inherited_style, display=display)
326 return
327 if isinstance(node, Prescript):
328 _append_prescript(parent, node, inherited_style, display=display)
329 return
330 if isinstance(node, Nary):
331 _append_nary(parent, node, inherited_style, display=display)
332 return
333 if isinstance(node, Delimiter):
334 delimiter = _math_element(parent, "d")
335 properties = _math_element(delimiter, "dPr")
336 _math_element(properties, "begChr", val=node.left)
337 _math_element(properties, "sepChr", val=node.separator)
338 _math_element(properties, "endChr", val=node.right)
339 _math_element(properties, "grow", val="on")
340 _append_control_style(properties, inherited_style)
341 for segment in node.segments:
342 body = _math_element(delimiter, "e")
343 _append_sequence(body, segment, inherited_style, display=display)
344 return
345 if isinstance(node, Matrix):
346 _append_matrix(parent, node, inherited_style, display=display)
347 return
348 if isinstance(node, AlignmentPoint):
349 _append_run(parent, "&", inherited_style)
350 return
351 if isinstance(node, EquationArray):
352 equation_array = _math_element(parent, "eqArr")
353 properties = _math_element(equation_array, "eqArrPr")
354 _math_element(properties, "baseJc", val="center")
355 for row in node.rows:
356 body = _math_element(equation_array, "e")
357 _append_sequence(body, row, inherited_style, display=display)
358 return
359 if isinstance(node, Accent):
360 accent = _math_element(parent, "acc")
361 properties = _math_element(accent, "accPr")
362 _math_element(properties, "chr", val=node.character)
363 _append_control_style(properties, inherited_style)
364 body = _math_element(accent, "e")
365 _append_sequence(body, node.body, inherited_style, display=display)
366 return
367 if isinstance(node, Bar):
368 bar = _math_element(parent, "bar")
369 properties = _math_element(bar, "barPr")
370 _math_element(properties, "pos", val=node.position)
371 _append_control_style(properties, inherited_style)
372 body = _math_element(bar, "e")
373 _append_sequence(body, node.body, inherited_style, display=display)
374 return
375 if isinstance(node, GroupChar):
376 group = _math_element(parent, "groupChr")
377 properties = _math_element(group, "groupChrPr")
378 _math_element(properties, "chr", val=node.character)
379 _math_element(properties, "pos", val=node.position)
380 _math_element(properties, "vertJc", val=node.vertical_justification)
381 _append_control_style(properties, inherited_style)
382 body = _math_element(group, "e")
383 _append_sequence(body, node.body, inherited_style, display=display)
384 return
385 if isinstance(node, Limit):
386 _append_limit(parent, node, inherited_style, display=display)
387 return
388 if isinstance(node, Function):
389 _append_function(parent, node, inherited_style, display=display)
390 return
391 if isinstance(node, OperatorEmulator):
392 box = _math_element(parent, "box")
393 properties = _math_element(box, "boxPr")
394 _math_element(properties, "opEmu", val="on")
395 _append_control_style(properties, inherited_style)
396 body = _math_element(box, "e")
397 _append_sequence(body, node.body, inherited_style, display=display)
398 return
399 if isinstance(node, Phantom):
400 phantom = _math_element(parent, "phant")
401 properties = _math_element(phantom, "phantPr")
402 if node.kind == "phantom":
403 _math_element(properties, "show", val="off")
404 elif node.kind == "hphantom":
405 _math_element(properties, "show", val="off")
406 _math_element(properties, "zeroAsc", val="on")
407 _math_element(properties, "zeroDesc", val="on")
408 elif node.kind == "vphantom":
409 _math_element(properties, "show", val="off")
410 _math_element(properties, "zeroWid", val="on")
411 else:
412 raise FormulaCompileError(f"Unsupported phantom kind: {node.kind!r}")
413 body = _math_element(phantom, "e")
414 _append_sequence(body, node.body, inherited_style, display=display)
415 return
416 if isinstance(node, BorderBox):
417 border = _math_element(parent, "borderBox")
418 properties = _math_element(border, "borderBoxPr")
419 if node.kind in {"cancel", "bcancel", "xcancel"}:
420 for side in ("hideTop", "hideBot", "hideLeft", "hideRight"):
421 _math_element(properties, side, val="on")
422 if node.kind == "cancel":
423 _math_element(properties, "strikeBLTR", val="on")
424 elif node.kind == "bcancel":
425 _math_element(properties, "strikeTLBR", val="on")
426 elif node.kind == "xcancel":
427 _math_element(properties, "strikeBLTR", val="on")
428 _math_element(properties, "strikeTLBR", val="on")
429 elif node.kind not in {"boxed", "fbox", "framebox"}:
430 raise FormulaCompileError(f"Unsupported border-box kind: {node.kind!r}")
431 _append_control_style(properties, inherited_style)
432 body = _math_element(border, "e")
433 _append_sequence(body, node.body, inherited_style, display=display)
434 return
435 raise FormulaCompileError(f"Unsupported internal formula node: {type(node).__name__}")
436
437
438 def _append_script(
439 parent: ET.Element,
440 node: Script,
441 inherited_style: RunStyle | None,
442 *,
443 display: bool,
444 ) -> None:
445 if node.subscript is not None and node.superscript is not None:
446 script = _math_element(parent, "sSubSup")
447 elif node.subscript is not None:
448 script = _math_element(parent, "sSub")
449 else:
450 script = _math_element(parent, "sSup")
451 base = _math_element(script, "e")
452 _append_node(base, node.base, inherited_style, display=display)
453 if node.subscript is not None:
454 subscript = _math_element(script, "sub")
455 _append_sequence(subscript, node.subscript, inherited_style, display=display)
456 if node.superscript is not None:
457 superscript = _math_element(script, "sup")
458 _append_sequence(superscript, node.superscript, inherited_style, display=display)
459
460
461 def _append_prescript(
462 parent: ET.Element,
463 node: Prescript,
464 inherited_style: RunStyle | None,
465 *,
466 display: bool,
467 ) -> None:
468 script = _math_element(parent, "sPre")
469 _math_element(script, "sPrePr")
470 subscript = _math_element(script, "sub")
471 if node.subscript is not None:
472 _append_sequence(subscript, node.subscript, inherited_style, display=display)
473 superscript = _math_element(script, "sup")
474 if node.superscript is not None:
475 _append_sequence(superscript, node.superscript, inherited_style, display=display)
476 base = _math_element(script, "e")
477 _append_node(base, node.base, inherited_style, display=display)
478
479
480 def _append_nary(
481 parent: ET.Element,
482 node: Nary,
483 inherited_style: RunStyle | None,
484 *,
485 display: bool,
486 ) -> None:
487 nary = _math_element(parent, "nary")
488 properties = _math_element(nary, "naryPr")
489 _math_element(properties, "chr", val=node.symbol)
490 if node.limit_modifier == "limits":
491 limit_location = "undOvr"
492 elif node.limit_modifier == "nolimits":
493 limit_location = "subSup"
494 elif node.category == "integral":
495 limit_location = "subSup"
496 else:
497 limit_location = "undOvr" if display else "subSup"
498 _math_element(properties, "limLoc", val=limit_location)
499 _math_element(properties, "grow", val="off")
500 _math_element(properties, "subHide", val="off" if node.subscript else "on")
501 _math_element(properties, "supHide", val="off" if node.superscript else "on")
502 _append_control_style(properties, inherited_style)
503 subscript = _math_element(nary, "sub")
504 if node.subscript is not None:
505 _append_sequence(subscript, node.subscript, inherited_style, display=display)
506 superscript = _math_element(nary, "sup")
507 if node.superscript is not None:
508 _append_sequence(superscript, node.superscript, inherited_style, display=display)
509 body = _math_element(nary, "e")
510 if node.body is not None:
511 _append_sequence(body, node.body, inherited_style, display=display)
512
513
514 def _append_matrix(
515 parent: ET.Element,
516 node: Matrix,
517 inherited_style: RunStyle | None,
518 *,
519 display: bool,
520 ) -> None:
521 matrix = _math_element(parent, "m")
522 properties = _math_element(matrix, "mPr")
523 _math_element(properties, "baseJc", val="center")
524 _math_element(properties, "plcHide", val="on")
525 columns = _math_element(properties, "mcs")
526 column_count = len(node.rows[0])
527 for _ in range(column_count):
528 column = _math_element(columns, "mc")
529 column_properties = _math_element(column, "mcPr")
530 _math_element(column_properties, "count", val="1")
531 for row_data in node.rows:
532 row = _math_element(matrix, "mr")
533 for cell_data in row_data:
534 cell = _math_element(row, "e")
535 _append_sequence(cell, cell_data, inherited_style, display=display)
536
537
538 def _append_limit(
539 parent: ET.Element,
540 node: Limit,
541 inherited_style: RunStyle | None,
542 *,
543 display: bool,
544 ) -> None:
545 base: Node = node.base
546 if node.lower is not None:
547 lower = _math_element(parent, "limLow")
548 _math_element(lower, "limLowPr")
549 expression = _math_element(lower, "e")
550 _append_node(expression, base, inherited_style, display=display)
551 limit = _math_element(lower, "lim")
552 _append_sequence(limit, node.lower, inherited_style, display=display)
553 base = Sequence(())
554 if node.upper is not None:
555 wrapper = ET.Element(_math_tag("limUpp"))
556 _math_element(wrapper, "limUppPr")
557 expression = _math_element(wrapper, "e")
558 expression.append(lower)
559 limit = _math_element(wrapper, "lim")
560 _append_sequence(limit, node.upper, inherited_style, display=display)
561 parent.remove(lower)
562 parent.append(wrapper)
563 return
564 if node.upper is not None:
565 upper = _math_element(parent, "limUpp")
566 _math_element(upper, "limUppPr")
567 expression = _math_element(upper, "e")
568 _append_node(expression, base, inherited_style, display=display)
569 limit = _math_element(upper, "lim")
570 _append_sequence(limit, node.upper, inherited_style, display=display)
571 return
572 _append_node(parent, base, inherited_style, display=display)
573
574
575 def _append_function(
576 parent: ET.Element,
577 node: Function,
578 inherited_style: RunStyle | None,
579 *,
580 display: bool,
581 ) -> None:
582 function = _math_element(parent, "func")
583 _math_element(function, "funcPr")
584 name_parent = _math_element(function, "fName")
585 name: Node = node.name
586 if node.subscript is not None or node.superscript is not None:
587 use_limits = node.limit_modifier == "limits" or (
588 node.limit_modifier != "nolimits" and node.limit_style and display
589 )
590 if use_limits:
591 name = Limit(name, lower=node.subscript, upper=node.superscript)
592 else:
593 name = Script(name, node.subscript, node.superscript)
594 _append_node(name_parent, name, inherited_style, display=display)
595 body = _math_element(function, "e")
596 if node.body is not None:
597 _append_sequence(body, node.body, inherited_style, display=display)
598
599
600 def _qualified_name(name: str) -> tuple[str | None, str]:
601 if name.startswith("{") and "}" in name:
602 namespace, local_name = name[1:].split("}", 1)
603 return namespace, local_name
604 return None, name
605
606
607 def _child_names(element: ET.Element) -> list[str]:
608 return [_qualified_name(child.tag)[1] for child in element]
609
610
611 def _require_children(
612 element: ET.Element,
613 expected: tuple[str, ...],
614 ) -> None:
615 element_name = _qualified_name(element.tag)[1]
616 actual = tuple(_child_names(element))
617 if actual != expected:
618 raise FormulaCompileError(
619 f"m:{element_name} children must be {expected!r}, found {actual!r}"
620 )
621
622
623 def _require_repeated_children(
624 element: ET.Element,
625 child_name: str,
626 *,
627 minimum: int,
628 maximum: int,
629 ) -> None:
630 element_name = _qualified_name(element.tag)[1]
631 actual = _child_names(element)
632 if (
633 not minimum <= len(actual) <= maximum
634 or any(name != child_name for name in actual)
635 ):
636 raise FormulaCompileError(
637 f"m:{element_name} must contain {minimum}..{maximum} "
638 f"m:{child_name} children"
639 )
640
641
642 def _validate_property_children(element: ET.Element, element_name: str) -> None:
643 expected_order = _PROPERTY_ORDER[element_name]
644 actual = _child_names(element)
645 if len(actual) != len(set(actual)):
646 raise FormulaCompileError(f"m:{element_name} contains duplicate properties")
647 unknown = [name for name in actual if name not in expected_order]
648 if unknown:
649 raise FormulaCompileError(
650 f"m:{element_name} contains unsupported properties: {unknown!r}"
651 )
652 order = [expected_order.index(name) for name in actual]
653 if order != sorted(order):
654 raise FormulaCompileError(f"m:{element_name} properties are out of order")
655 if element_name == "rPr" and "nor" in actual and (
656 "scr" in actual or "sty" in actual
657 ):
658 raise FormulaCompileError(
659 "m:rPr cannot combine m:nor with m:scr or m:sty"
660 )
661
662
663 def _math_value(element: ET.Element) -> str:
664 value = element.get(_math_tag("val"))
665 if value is None:
666 element_name = _qualified_name(element.tag)[1]
667 raise FormulaCompileError(f"m:{element_name} requires m:val")
668 return value
669
670
671 def _validate_math_value(element: ET.Element, element_name: str) -> None:
672 value = _math_value(element)
673 allowed_values = {
674 "baseJc": {"top", "center", "bot"},
675 "jc": {"left", "right", "center", "centerGroup"},
676 "limLoc": {"subSup", "undOvr"},
677 "pos": {"top", "bot"},
678 "scr": {
679 "roman", "sans-serif", "monospace", "double-struck",
680 "script", "fraktur",
681 },
682 "sty": {"p", "b", "i", "bi"},
683 "type": {"bar", "lin", "noBar", "skw"},
684 "vertJc": {"top", "bot"},
685 }
686 if element_name in _ON_OFF_ELEMENTS:
687 if value not in {"on", "off", "true", "false", "1", "0"}:
688 raise FormulaCompileError(
689 f"m:{element_name} has invalid on/off value: {value!r}"
690 )
691 return
692 if element_name == "count":
693 if not value.isascii() or not value.isdigit() or not 1 <= int(value) <= 64:
694 raise FormulaCompileError("m:count must be an integer from 1 to 64")
695 return
696 if element_name in {"begChr", "chr", "endChr", "sepChr"}:
697 if len(value) > 1:
698 raise FormulaCompileError(
699 f"m:{element_name} must contain at most one character"
700 )
701 return
702 allowed = allowed_values.get(element_name)
703 if allowed is not None and value not in allowed:
704 raise FormulaCompileError(
705 f"m:{element_name} has invalid value: {value!r}"
706 )
707
708
709 def _validate_drawing_element(element: ET.Element, element_name: str) -> None:
710 attributes = {
711 _qualified_name(name)[1]: value for name, value in element.attrib.items()
712 }
713 children = _child_names(element)
714 if element_name == "rPr":
715 unknown = set(attributes) - {"b", "dirty", "i", "lang", "sz"}
716 if unknown:
717 raise FormulaCompileError(
718 f"a:rPr contains unsupported attributes: {sorted(unknown)!r}"
719 )
720 for name in ("b", "dirty", "i"):
721 if name in attributes and attributes[name] not in {
722 "on", "off", "true", "false", "1", "0",
723 }:
724 raise FormulaCompileError(
725 f"a:rPr@{name} has invalid on/off value"
726 )
727 if "sz" in attributes and (
728 not attributes["sz"].isascii()
729 or not attributes["sz"].isdigit()
730 or not 100 <= int(attributes["sz"]) <= 400_000
731 ):
732 raise FormulaCompileError("a:rPr@sz must be from 100 to 400000")
733 expected_order = ("solidFill", "latin", "ea", "cs")
734 if len(children) != len(set(children)):
735 raise FormulaCompileError("a:rPr contains duplicate child properties")
736 if any(name not in expected_order for name in children):
737 raise FormulaCompileError("a:rPr contains unsupported child properties")
738 order = [expected_order.index(name) for name in children]
739 if order != sorted(order):
740 raise FormulaCompileError("a:rPr child properties are out of order")
741 return
742 if element_name == "solidFill":
743 if attributes or children != ["srgbClr"]:
744 raise FormulaCompileError("a:solidFill must contain one a:srgbClr")
745 return
746 if element_name == "srgbClr":
747 if children or set(attributes) != {"val"} or not re.fullmatch(
748 r"[0-9A-Fa-f]{6}", attributes["val"]
749 ):
750 raise FormulaCompileError("a:srgbClr@val must be six hexadecimal digits")
751 return
752 if element_name in {"latin", "ea", "cs"}:
753 if children or set(attributes) != {"typeface"} or not attributes["typeface"]:
754 raise FormulaCompileError(
755 f"a:{element_name} must contain one non-empty typeface attribute"
756 )
757
758
759 def _validate_math_structure(element: ET.Element, element_name: str) -> None:
760 if element_name in _ARGUMENT_CONTAINERS:
761 children = list(element)
762 if element_name == "oMath" and not children:
763 raise FormulaCompileError("m:oMath must contain a math expression")
764 for child in children:
765 namespace, child_name = _qualified_name(child.tag)
766 if namespace != MATH_NS or child_name not in _ARGUMENT_ELEMENTS:
767 raise FormulaCompileError(
768 f"m:{element_name} contains an invalid math argument child"
769 )
770 return
771 fixed_children = {
772 "acc": ("accPr", "e"),
773 "bar": ("barPr", "e"),
774 "borderBox": ("borderBoxPr", "e"),
775 "box": ("boxPr", "e"),
776 "f": ("fPr", "num", "den"),
777 "func": ("funcPr", "fName", "e"),
778 "groupChr": ("groupChrPr", "e"),
779 "limLow": ("limLowPr", "e", "lim"),
780 "limUpp": ("limUppPr", "e", "lim"),
781 "mc": ("mcPr",),
782 "nary": ("naryPr", "sub", "sup", "e"),
783 "phant": ("phantPr", "e"),
784 "rad": ("radPr", "deg", "e"),
785 "sPre": ("sPrePr", "sub", "sup", "e"),
786 "sSub": ("e", "sub"),
787 "sSubSup": ("e", "sub", "sup"),
788 "sSup": ("e", "sup"),
789 }
790 if element_name in fixed_children:
791 _require_children(element, fixed_children[element_name])
792 return
793 if element_name == "oMathPara":
794 children = tuple(_child_names(element))
795 if children not in {("oMath",), ("oMathParaPr", "oMath")}:
796 raise FormulaCompileError(
797 "m:oMathPara must contain optional m:oMathParaPr then one m:oMath"
798 )
799 return
800 if element_name == "d":
801 children = _child_names(element)
802 rows = children[1:] if children and children[0] == "dPr" else []
803 if not 1 <= len(rows) <= 64 or any(name != "e" for name in rows):
804 raise FormulaCompileError("m:d must contain m:dPr then 1..64 m:e children")
805 return
806 if element_name == "eqArr":
807 children = _child_names(element)
808 offset = 1 if children and children[0] == "eqArrPr" else 0
809 rows = children[offset:]
810 if not 1 <= len(rows) <= 64 or any(name != "e" for name in rows):
811 raise FormulaCompileError("m:eqArr must contain 1..64 m:e rows")
812 return
813 if element_name == "m":
814 children = _child_names(element)
815 rows = children[1:] if children and children[0] == "mPr" else []
816 if not 1 <= len(rows) <= 256 or any(name != "mr" for name in rows):
817 raise FormulaCompileError("m:m must contain m:mPr then 1..256 m:mr rows")
818 return
819 if element_name == "mr":
820 _require_repeated_children(element, "e", minimum=1, maximum=64)
821 return
822 if element_name == "mcs":
823 _require_repeated_children(element, "mc", minimum=1, maximum=64)
824 return
825 if element_name == "r":
826 children = [(_qualified_name(child.tag), child) for child in element]
827 index = 0
828 if index < len(children) and children[index][0] == (MATH_NS, "rPr"):
829 index += 1
830 if index < len(children) and children[index][0] == (DRAWING_NS, "rPr"):
831 index += 1
832 if index != len(children) - 1 or children[index][0] != (MATH_NS, "t"):
833 raise FormulaCompileError(
834 "m:r must contain optional m:rPr, optional a:rPr, then one m:t"
835 )
836 return
837 if element_name == "ctrlPr":
838 children = [(_qualified_name(child.tag), child) for child in element]
839 if len(children) > 1 or (
840 children and children[0][0] != (DRAWING_NS, "rPr")
841 ):
842 raise FormulaCompileError(
843 "m:ctrlPr may contain only one optional a:rPr"
844 )
845 return
846 if element_name in _PROPERTY_ORDER:
847 _validate_property_children(element, element_name)
848 return
849 if element_name in _EMPTY_PROPERTY_ELEMENTS:
850 if list(element):
851 raise FormulaCompileError(f"m:{element_name} must be empty")
852 return
853 if element_name in _VALUE_ELEMENTS:
854 if list(element):
855 raise FormulaCompileError(f"m:{element_name} must not contain children")
856 _validate_math_value(element, element_name)
857 return
858 if element_name == "t" and list(element):
859 raise FormulaCompileError("m:t must not contain children")
860
861
862 def _validate_matrix_dimensions(root: ET.Element) -> None:
863 for matrix in root.iter(_math_tag("m")):
864 properties = matrix.find(_math_tag("mPr"))
865 columns = properties.find(_math_tag("mcs")) if properties is not None else None
866 if columns is None:
867 raise FormulaCompileError("m:mPr must contain m:mcs")
868 column_count = 0
869 for column in columns:
870 column_properties = column.find(_math_tag("mcPr"))
871 count = (
872 column_properties.find(_math_tag("count"))
873 if column_properties is not None
874 else None
875 )
876 if count is None:
877 raise FormulaCompileError("m:mcPr must contain m:count")
878 column_count += int(_math_value(count))
879 if not 1 <= column_count <= 64:
880 raise FormulaCompileError("matrix column count must be from 1 to 64")
881 for row in matrix.findall(_math_tag("mr")):
882 if len(row.findall(_math_tag("e"))) != column_count:
883 raise FormulaCompileError(
884 "every matrix row must match the declared column count"
885 )
886
887
888 def _validate_xml_tree(root: ET.Element) -> None:
889 namespace, local_name = _qualified_name(root.tag)
890 if namespace != MATH_NS or local_name not in {"oMathPara", "oMath"}:
891 raise FormulaCompileError("OMML root must be m:oMathPara or m:oMath")
892
893 math_roots = 0
894 pending = [root]
895 while pending:
896 element = pending.pop()
897 pending.extend(reversed(element))
898 element_namespace, element_name = _qualified_name(element.tag)
899 if element_namespace == MATH_NS and element_name not in _MATH_ELEMENTS:
900 raise FormulaCompileError(
901 f"OMML contains unsupported math element: {element_name!r}"
902 )
903 if element_namespace == DRAWING_NS and element_name not in _DRAWING_ELEMENTS:
904 raise FormulaCompileError(
905 f"OMML contains unsupported DrawingML element: {element_name!r}"
906 )
907 if element_namespace not in {MATH_NS, DRAWING_NS}:
908 raise FormulaCompileError(
909 f"OMML contains unsupported element namespace: {element_namespace!r}"
910 )
911 if element_namespace == MATH_NS and element_name in {"oMathPara", "oMath"}:
912 math_roots += 1
913 for attribute, value in element.attrib.items():
914 attribute_namespace, attribute_name = _qualified_name(attribute)
915 if (
916 attribute_namespace == MATH_NS
917 and attribute_name == "val"
918 and element_namespace == MATH_NS
919 and element_name in _VALUE_ELEMENTS
920 ):
921 continue
922 if (
923 attribute_namespace in {None, DRAWING_NS}
924 and element_namespace == DRAWING_NS
925 and attribute_name in _DRAWING_ATTRIBUTES
926 ):
927 continue
928 if (
929 attribute_namespace == XML_NS
930 and element_namespace == MATH_NS
931 and element_name == "t"
932 and attribute_name == "space"
933 and value == "preserve"
934 ):
935 continue
936 raise FormulaCompileError(
937 f"OMML contains unsupported attribute {attribute_name!r}"
938 )
939 if element_namespace == MATH_NS:
940 _validate_math_structure(element, element_name)
941 else:
942 _validate_drawing_element(element, element_name)
943 if element.text:
944 if not (
945 element_namespace == MATH_NS and element_name == "t"
946 ) and element.text.strip():
947 raise FormulaCompileError(
948 f"OMML text is only allowed inside m:t, found in {element_name!r}"
949 )
950 if element.tail and element.tail.strip():
951 raise FormulaCompileError("OMML contains unexpected trailing text")
952
953 expected_roots = 2 if local_name == "oMathPara" else 1
954 if math_roots != expected_roots:
955 raise FormulaCompileError("OMML must contain exactly one math expression")
956 _validate_matrix_dimensions(root)
957 if local_name == "oMathPara":
958 direct_expressions = [
959 child
960 for child in root
961 if _qualified_name(child.tag) == (MATH_NS, "oMath")
962 ]
963 if len(direct_expressions) != 1:
964 raise FormulaCompileError(
965 "m:oMathPara must contain exactly one direct m:oMath expression"
966 )
967
968
969 def _validate_xml_depth(root: ET.Element) -> None:
970 pending: list[tuple[ET.Element, int]] = [(root, 1)]
971 while pending:
972 element, depth = pending.pop()
973 if depth > _MAX_OMML_DEPTH:
974 raise FormulaCompileError(
975 f"OMML nesting exceeds {_MAX_OMML_DEPTH} levels"
976 )
977 pending.extend((child, depth + 1) for child in reversed(element))
978
979
980 def _parse_omml_with_resource_limits(xml: str) -> ET.Element:
981 if not isinstance(xml, str):
982 raise FormulaCompileError("OMML fragment must be a string")
983 if not xml.strip():
984 raise FormulaCompileError("OMML fragment is empty")
985 if len(xml) > _MAX_OMML_LENGTH:
986 raise FormulaCompileError(
987 f"OMML fragment exceeds the {_MAX_OMML_LENGTH}-character limit"
988 )
989 if _FORBIDDEN_XML_RE.search(xml):
990 raise FormulaCompileError("DOCTYPE and ENTITY declarations are forbidden in OMML")
991 try:
992 root = ET.fromstring(xml)
993 except (ET.ParseError, RecursionError) as exc:
994 raise FormulaCompileError(f"Invalid OMML XML: {exc}") from exc
995 _validate_xml_depth(root)
996 return root
997
998
999 def _serialize_omml_with_resource_limits(root: ET.Element) -> str:
1000 ET.register_namespace("m", MATH_NS)
1001 ET.register_namespace("a", DRAWING_NS)
1002 try:
1003 canonical = ET.tostring(root, encoding="unicode", short_empty_elements=True)
1004 except RecursionError as exc:
1005 raise FormulaCompileError("OMML nesting exceeds the XML serializer limit") from exc
1006 if len(canonical) > _MAX_OMML_LENGTH:
1007 raise FormulaCompileError(
1008 f"Canonical OMML exceeds the {_MAX_OMML_LENGTH}-character limit"
1009 )
1010 return canonical
1011
1012
1013 def validate_omml_resource_limits(xml: str) -> str:
1014 """Apply only the shared serialized-size and XML-depth resource limits."""
1015 root = _parse_omml_with_resource_limits(xml)
1016 return _serialize_omml_with_resource_limits(root)
1017
1018
1019 def validate_omml_fragment(xml: str) -> str:
1020 """Validate emitted Office Math XML and canonicalize namespace prefixes."""
1021 root = _parse_omml_with_resource_limits(xml)
1022 _validate_xml_tree(root)
1023 return _serialize_omml_with_resource_limits(root)
1024
1025
1026 def emit_omml(expression: Sequence, *, display: bool) -> str:
1027 """Emit one block or inline Office Math root from the parsed AST."""
1028 if display:
1029 root = ET.Element(_math_tag("oMathPara"))
1030 properties = _math_element(root, "oMathParaPr")
1031 _math_element(properties, "jc", val="center")
1032 math = _math_element(root, "oMath")
1033 else:
1034 root = ET.Element(_math_tag("oMath"))
1035 math = root
1036 _append_sequence(math, expression, None, display=display)
1037 ET.register_namespace("m", MATH_NS)
1038 ET.register_namespace("a", DRAWING_NS)
1039 xml = ET.tostring(root, encoding="unicode", short_empty_elements=True)
1040 return validate_omml_fragment(xml)
1041
1042
1043 __all__ = [
1044 "DRAWING_NS",
1045 "MATH_NS",
1046 "emit_omml",
1047 "validate_omml_fragment",
1048 "validate_omml_resource_limits",
1049 ]
1050
1050 lines PYTHON