返回 ppt-master
formula_import.py
根目录 / skills / ppt-master / scripts / pptx_to_svg / formula_import.py
1 """Import PPT Master-owned Office Math into canonical SVG formula markers.
2
3 The reverse contract is deliberately narrow: accept only the closed OMML
4 vocabulary already validated by the native formula compiler, serialize that
5 structure to compiler-accepted LaTeX, and build a plain-text SVG preview.
6 Unknown third-party OMML remains outside this module's reconstruction claim.
7 """
8
9 from __future__ import annotations
10
11 from collections import Counter
12 from dataclasses import dataclass
13 from xml.etree import ElementTree as ET
14
15 from svg_to_pptx.native_objects.formula_compiler import (
16 FormulaCompileError,
17 compile_latex_to_inline_omml,
18 compile_latex_to_omml,
19 validate_omml_fragment,
20 )
21 from svg_to_pptx.native_objects.formula_profile import (
22 ACCENT_COMMANDS,
23 NARY_COMMANDS,
24 )
25
26
27 MATH_NS = "http://schemas.openxmlformats.org/officeDocument/2006/math"
28 DRAWING_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
29 A14_NS = "http://schemas.microsoft.com/office/drawing/2010/main"
30
31 _M = f"{{{MATH_NS}}}"
32 _A = f"{{{DRAWING_NS}}}"
33 _A14_M = f"{{{A14_NS}}}m"
34 _PROPERTY_SUFFIX = "Pr"
35
36 _NARY_COMMAND_BY_SYMBOL = {
37 symbol: command
38 for command, (symbol, _category) in NARY_COMMANDS.items()
39 }
40 _ACCENT_COMMAND_BY_CHARACTER = {
41 character: command
42 for command, character in ACCENT_COMMANDS.items()
43 }
44 _GROUP_COMMANDS = {
45 ("⏞", "top", "bot"): "overbrace",
46 ("⏟", "bot", "top"): "underbrace",
47 ("→", "bot", "top"): "underrightarrow",
48 ("←", "bot", "top"): "underleftarrow",
49 ("↔", "bot", "top"): "underleftrightarrow",
50 ("→", "top", "bot"): "overrightarrow",
51 ("←", "top", "bot"): "overleftarrow",
52 ("↔", "top", "bot"): "overleftrightarrow",
53 }
54 _DELIMITER_COMMANDS = {
55 "{": r"\{",
56 "}": r"\}",
57 "⟨": r"\langle",
58 "⟩": r"\rangle",
59 "⌊": r"\lfloor",
60 "⌋": r"\rfloor",
61 "⌈": r"\lceil",
62 "⌉": r"\rceil",
63 "‖": r"\Vert",
64 "⟦": "⟦",
65 "⟧": "⟧",
66 }
67 _SPACING_LATEX = {
68 "\u200b": r"\!",
69 "\u2009": r"\,",
70 "\u205f": r"\:",
71 "\u2004": r"\;",
72 "\u2002": r"\enspace{}",
73 "\u2003": r"\quad{}",
74 "\u00a0": "~",
75 }
76
77
78 class FormulaImportError(ValueError):
79 """Raised when source OMML is outside the reversible project contract."""
80
81
82 @dataclass(frozen=True)
83 class FormulaImport:
84 """Canonical formula source plus a visible SVG-preview representation."""
85
86 latex: str
87 preview: str
88 font_size_px: float
89 color: str
90 align: str
91 language: str
92
93
94 def import_formula(math_zone: ET.Element, *, display: bool) -> FormulaImport:
95 """Validate and reconstruct one ``a14:m`` formula zone."""
96 if math_zone.tag != _A14_M:
97 raise FormulaImportError("formula carrier must be one a14:m element")
98 children = [child for child in math_zone if isinstance(child.tag, str)]
99 if len(children) != 1:
100 raise FormulaImportError("a14:m must contain exactly one Office Math root")
101 root = children[0]
102 expected = f"{_M}{'oMathPara' if display else 'oMath'}"
103 if root.tag != expected:
104 kind = "block" if display else "inline"
105 raise FormulaImportError(
106 f"{kind} formula requires m:{expected.rsplit('}', 1)[-1]}"
107 )
108
109 try:
110 canonical = validate_omml_fragment(
111 ET.tostring(root, encoding="unicode", short_empty_elements=True)
112 )
113 validated_root = ET.fromstring(canonical)
114 latex = _serialize_root(validated_root)
115 if display:
116 compile_latex_to_omml(latex)
117 else:
118 compile_latex_to_inline_omml(latex)
119 except (FormulaCompileError, ET.ParseError, RecursionError) as exc:
120 raise FormulaImportError(str(exc)) from exc
121
122 preview = _preview_root(validated_root).strip()
123 if not latex.strip() or not preview:
124 raise FormulaImportError("formula reconstruction produced empty content")
125 font_size_px, color, language = _dominant_run_style(validated_root)
126 return FormulaImport(
127 latex=latex,
128 preview=preview,
129 font_size_px=font_size_px,
130 color=color,
131 align=_formula_alignment(validated_root),
132 language=language,
133 )
134
135
136 def opaque_formula_preview(math_zone: ET.Element) -> str:
137 """Return readable source text without claiming native reconstruction."""
138 text = "".join(
139 item.text or ""
140 for item in math_zone.iter(f"{_M}t")
141 ).strip()
142 return text or "[unsupported formula]"
143
144
145 def _serialize_root(root: ET.Element) -> str:
146 if root.tag == f"{_M}oMathPara":
147 math = root.find(f"{_M}oMath")
148 if math is None:
149 raise FormulaImportError("m:oMathPara is missing m:oMath")
150 return _serialize_children(math)
151 if root.tag == f"{_M}oMath":
152 return _serialize_children(root)
153 raise FormulaImportError("unsupported Office Math root")
154
155
156 def _serialize_children(parent: ET.Element, *, alignment: bool = False) -> str:
157 return "".join(
158 _serialize_node(child, alignment=alignment)
159 for child in parent
160 if _local_name(child) not in {_PROPERTY_SUFFIX, "ctrlPr"}
161 and not _local_name(child).endswith(_PROPERTY_SUFFIX)
162 )
163
164
165 def _serialize_argument(owner: ET.Element, name: str, *, alignment: bool = False) -> str:
166 element = owner.find(f"{_M}{name}")
167 if element is None:
168 raise FormulaImportError(
169 f"m:{_local_name(owner)} is missing required m:{name}"
170 )
171 return _serialize_children(element, alignment=alignment)
172
173
174 def _serialize_node(element: ET.Element, *, alignment: bool = False) -> str:
175 name = _local_name(element)
176 if name == "r":
177 return _serialize_run(element, alignment=alignment)
178 if name == "f":
179 kind = _property_value(element, "fPr", "type", "bar")
180 command = {"bar": "frac", "skw": "ifrac"}.get(kind)
181 numerator = _serialize_argument(element, "num")
182 denominator = _serialize_argument(element, "den")
183 if command is not None:
184 return f"\\{command}{{{numerator}}}{{{denominator}}}"
185 if kind == "noBar":
186 return (
187 r"\genfrac{}{}{0pt}{}"
188 f"{{{numerator}}}{{{denominator}}}"
189 )
190 raise FormulaImportError(f"unsupported reversible fraction type: {kind!r}")
191 if name == "rad":
192 body = _serialize_argument(element, "e")
193 degree = _serialize_argument(element, "deg")
194 return f"\\sqrt[{degree}]{{{body}}}" if degree else f"\\sqrt{{{body}}}"
195 if name in {"sSub", "sSup", "sSubSup"}:
196 base = _serialize_argument(element, "e")
197 subscript = _serialize_optional_argument(element, "sub")
198 superscript = _serialize_optional_argument(element, "sup")
199 return _scripted(f"{{{base}}}", subscript, superscript)
200 if name == "sPre":
201 base = _serialize_argument(element, "e")
202 subscript = _serialize_optional_argument(element, "sub")
203 superscript = _serialize_optional_argument(element, "sup")
204 return _scripted("", subscript, superscript) + f"{{{base}}}"
205 if name == "nary":
206 symbol = _property_value(element, "naryPr", "chr", "")
207 command = _NARY_COMMAND_BY_SYMBOL.get(symbol)
208 if command is None:
209 raise FormulaImportError(f"unsupported reversible n-ary symbol: {symbol!r}")
210 source = f"\\{command}"
211 # Serialize the source location explicitly when it uses under/over
212 # limits. ``subSup`` is the neutral default in both formula contexts.
213 default_location = "subSup"
214 location = _property_value(element, "naryPr", "limLoc", default_location)
215 if location != default_location:
216 source += r"\limits" if location == "undOvr" else r"\nolimits"
217 source = _scripted(
218 source,
219 _serialize_optional_argument(element, "sub"),
220 _serialize_optional_argument(element, "sup"),
221 )
222 body = _serialize_optional_argument(element, "e")
223 return source + (f"{{{body}}}" if body else "")
224 if name == "d":
225 left = _property_value(element, "dPr", "begChr", "(")
226 right = _property_value(element, "dPr", "endChr", ")")
227 separator = _property_value(element, "dPr", "sepChr", "")
228 segments = [
229 _serialize_children(child)
230 for child in element.findall(f"{_M}e")
231 ]
232 middle = f"\\middle{_delimiter(separator, side='middle')}" if separator else ""
233 return (
234 f"\\left{_delimiter(left, side='left')}"
235 + middle.join(segments)
236 + f"\\right{_delimiter(right, side='right')}"
237 )
238 if name == "m":
239 rows = []
240 for row in element.findall(f"{_M}mr"):
241 rows.append("&".join(
242 _serialize_children(cell)
243 for cell in row.findall(f"{_M}e")
244 ))
245 return r"\begin{matrix}" + r"\\".join(rows) + r"\end{matrix}"
246 if name == "eqArr":
247 rows = [
248 _serialize_children(row, alignment=True)
249 for row in element.findall(f"{_M}e")
250 ]
251 return r"\begin{aligned}" + r"\\".join(rows) + r"\end{aligned}"
252 if name == "acc":
253 character = _property_value(element, "accPr", "chr", "")
254 command = _ACCENT_COMMAND_BY_CHARACTER.get(character)
255 if character == "̸":
256 command = "not"
257 if command is None:
258 raise FormulaImportError(f"unsupported reversible accent: {character!r}")
259 return f"\\{command}{{{_serialize_argument(element, 'e')}}}"
260 if name == "bar":
261 position = _property_value(element, "barPr", "pos", "top")
262 command = "overline" if position == "top" else "underline"
263 return f"\\{command}{{{_serialize_argument(element, 'e')}}}"
264 if name == "groupChr":
265 key = (
266 _property_value(element, "groupChrPr", "chr", ""),
267 _property_value(element, "groupChrPr", "pos", "top"),
268 _property_value(element, "groupChrPr", "vertJc", "bot"),
269 )
270 command = _GROUP_COMMANDS.get(key)
271 if command is None:
272 raise FormulaImportError(f"unsupported reversible group character: {key!r}")
273 return f"\\{command}{{{_serialize_argument(element, 'e')}}}"
274 if name == "limLow":
275 base = _serialize_argument(element, "e")
276 lower = _serialize_argument(element, "lim")
277 return f"\\underset{{{lower}}}{{{base}}}"
278 if name == "limUpp":
279 base = _serialize_argument(element, "e")
280 upper = _serialize_argument(element, "lim")
281 return f"\\overset{{{upper}}}{{{base}}}"
282 if name == "func":
283 function_name = _serialize_argument(element, "fName")
284 body = _serialize_optional_argument(element, "e")
285 source = f"\\mathop{{{function_name}}}"
286 return source + (f"{{{body}}}" if body else "")
287 if name == "box":
288 enabled = _property_value(element, "boxPr", "opEmu", "off")
289 if enabled not in {"on", "true", "1"}:
290 raise FormulaImportError("only operator-emulator m:box is reversible")
291 return f"\\mathrel{{{_serialize_argument(element, 'e')}}}"
292 if name == "phant":
293 properties = element.find(f"{_M}phantPr")
294 zero_width = _on_property(properties, "zeroWid")
295 zero_ascent = _on_property(properties, "zeroAsc")
296 zero_descent = _on_property(properties, "zeroDesc")
297 if zero_width:
298 command = "vphantom"
299 elif zero_ascent and zero_descent:
300 command = "hphantom"
301 else:
302 command = "phantom"
303 return f"\\{command}{{{_serialize_argument(element, 'e')}}}"
304 if name == "borderBox":
305 properties = element.find(f"{_M}borderBoxPr")
306 rising = _on_property(properties, "strikeBLTR")
307 falling = _on_property(properties, "strikeTLBR")
308 hidden = all(
309 _on_property(properties, side)
310 for side in ("hideTop", "hideBot", "hideLeft", "hideRight")
311 )
312 if hidden and rising and falling:
313 command = "xcancel"
314 elif hidden and rising:
315 command = "cancel"
316 elif hidden and falling:
317 command = "bcancel"
318 elif not hidden and not rising and not falling:
319 command = "boxed"
320 else:
321 raise FormulaImportError("unsupported reversible border-box properties")
322 return f"\\{command}{{{_serialize_argument(element, 'e')}}}"
323 if name in {"e", "deg", "den", "fName", "lim", "num", "sub", "sup"}:
324 return _serialize_children(element, alignment=alignment)
325 raise FormulaImportError(f"unsupported reversible Office Math element: m:{name}")
326
327
328 def _serialize_optional_argument(owner: ET.Element, name: str) -> str:
329 element = owner.find(f"{_M}{name}")
330 return _serialize_children(element) if element is not None else ""
331
332
333 def _scripted(base: str, subscript: str, superscript: str) -> str:
334 if subscript:
335 base += f"_{{{subscript}}}"
336 if superscript:
337 base += f"^{{{superscript}}}"
338 return base
339
340
341 def _serialize_run(run: ET.Element, *, alignment: bool) -> str:
342 text_element = run.find(f"{_M}t")
343 value = text_element.text or "" if text_element is not None else ""
344 properties = run.find(f"{_M}rPr")
345 literal = _on_property(properties, "lit")
346 normal = _on_property(properties, "nor")
347 source = _escape_text(value, alignment=alignment and not literal, text_mode=normal)
348 if not source:
349 return ""
350 drawing = run.find(f"{_A}rPr")
351 if normal:
352 typeface = ""
353 if drawing is not None:
354 latin = drawing.find(f"{_A}latin")
355 typeface = latin.get("typeface", "") if latin is not None else ""
356 text_command = {
357 "Arial": "textsf",
358 "Courier New": "texttt",
359 }.get(typeface, "text")
360 source = f"\\{text_command}{{{source}}}"
361 if drawing is not None and _drawing_on(drawing, "b"):
362 source = f"\\textbf{{{source}}}"
363 if drawing is not None and _drawing_on(drawing, "i"):
364 source = f"\\textit{{{source}}}"
365 else:
366 script = _property_child_value(properties, "scr")
367 style = _property_child_value(properties, "sty")
368 command = {
369 "sans-serif": "mathsf",
370 "monospace": "mathtt",
371 "double-struck": "mathbb",
372 "script": "mathcal",
373 "fraktur": "mathfrak",
374 }.get(script or "")
375 if command is None:
376 command = {
377 "p": "mathrm",
378 "b": "mathbf",
379 "i": "mathit",
380 "bi": "boldsymbol",
381 }.get(style or "")
382 if command is not None:
383 source = f"\\{command}{{{source}}}"
384 color = _drawing_color(drawing)
385 if color is not None:
386 source = f"\\textcolor{{#{color}}}{{{source}}}"
387 return source
388
389
390 def _escape_text(value: str, *, alignment: bool, text_mode: bool) -> str:
391 parts: list[str] = []
392 for character in value:
393 if character in _SPACING_LATEX and not text_mode:
394 parts.append(_SPACING_LATEX[character])
395 elif character == " " and not text_mode:
396 parts.append(r"\ ")
397 elif character == "&":
398 parts.append("&" if alignment else r"\&")
399 elif character in "{}_%#$":
400 parts.append("\\" + character)
401 elif character == "\\":
402 parts.append(r"\backslash{}")
403 elif character == "~" and not text_mode:
404 parts.append(r"\text{~}")
405 elif character == "^" and not text_mode:
406 parts.append(r"\text{^}")
407 else:
408 parts.append(character)
409 return "".join(parts)
410
411
412 def _delimiter(value: str, *, side: str) -> str:
413 if not value:
414 return "."
415 if value == "|":
416 return "|"
417 command = _DELIMITER_COMMANDS.get(value)
418 if command is not None:
419 if value == "‖" and side == "left":
420 command = r"\lVert"
421 if value == "‖" and side == "right":
422 command = r"\rVert"
423 if command.startswith("\\") and command[1:].isalpha():
424 return command + " "
425 return command
426 if len(value) == 1:
427 return value
428 raise FormulaImportError(f"unsupported reversible delimiter: {value!r}")
429
430
431 def _preview_root(root: ET.Element) -> str:
432 math = root.find(f"{_M}oMath") if root.tag == f"{_M}oMathPara" else root
433 if math is None:
434 return ""
435 return _preview_children(math)
436
437
438 def _preview_children(parent: ET.Element) -> str:
439 return "".join(
440 _preview_node(child)
441 for child in parent
442 if not _local_name(child).endswith(_PROPERTY_SUFFIX)
443 and _local_name(child) != "ctrlPr"
444 )
445
446
447 def _preview_argument(owner: ET.Element, name: str) -> str:
448 child = owner.find(f"{_M}{name}")
449 return _preview_children(child) if child is not None else ""
450
451
452 def _preview_node(element: ET.Element) -> str:
453 name = _local_name(element)
454 if name == "r":
455 text = element.find(f"{_M}t")
456 return text.text or "" if text is not None else ""
457 if name == "f":
458 return f"({_preview_argument(element, 'num')})/({_preview_argument(element, 'den')})"
459 if name == "rad":
460 degree = _preview_argument(element, "deg")
461 prefix = f"√[{degree}]" if degree else "√"
462 return f"{prefix}({_preview_argument(element, 'e')})"
463 if name in {"sSub", "sSup", "sSubSup"}:
464 base = _preview_argument(element, "e")
465 subscript = _preview_argument(element, "sub")
466 superscript = _preview_argument(element, "sup")
467 return _preview_script(base, subscript, superscript)
468 if name == "sPre":
469 prefix = _preview_script(
470 "",
471 _preview_argument(element, "sub"),
472 _preview_argument(element, "sup"),
473 )
474 return prefix + _preview_argument(element, "e")
475 if name == "nary":
476 symbol = _property_value(element, "naryPr", "chr", "")
477 return _preview_script(
478 symbol,
479 _preview_argument(element, "sub"),
480 _preview_argument(element, "sup"),
481 ) + _preview_argument(element, "e")
482 if name == "d":
483 left = _property_value(element, "dPr", "begChr", "(")
484 right = _property_value(element, "dPr", "endChr", ")")
485 separator = _property_value(element, "dPr", "sepChr", "")
486 segments = [_preview_children(child) for child in element.findall(f"{_M}e")]
487 return left + separator.join(segments) + right
488 if name == "m":
489 rows = [
490 ", ".join(_preview_children(cell) for cell in row.findall(f"{_M}e"))
491 for row in element.findall(f"{_M}mr")
492 ]
493 return "[" + "; ".join(rows) + "]"
494 if name == "eqArr":
495 return " ".join(
496 _preview_children(row)
497 for row in element.findall(f"{_M}e")
498 )
499 if name == "acc":
500 return _preview_argument(element, "e") + _property_value(element, "accPr", "chr", "")
501 if name == "bar":
502 marker = "¯" if _property_value(element, "barPr", "pos", "top") == "top" else "_"
503 return marker + f"({_preview_argument(element, 'e')})"
504 if name == "groupChr":
505 marker = _property_value(element, "groupChrPr", "chr", "")
506 return marker + f"({_preview_argument(element, 'e')})"
507 if name == "limLow":
508 return _preview_script(
509 _preview_argument(element, "e"),
510 _preview_argument(element, "lim"),
511 "",
512 )
513 if name == "limUpp":
514 return _preview_script(
515 _preview_argument(element, "e"),
516 "",
517 _preview_argument(element, "lim"),
518 )
519 if name == "func":
520 return _preview_argument(element, "fName") + _preview_argument(element, "e")
521 if name in {"box", "phant"}:
522 return _preview_argument(element, "e")
523 if name == "borderBox":
524 return "□(" + _preview_argument(element, "e") + ")"
525 if name in {"e", "deg", "den", "fName", "lim", "num", "sub", "sup"}:
526 return _preview_children(element)
527 return ""
528
529
530 def _preview_script(base: str, subscript: str, superscript: str) -> str:
531 if subscript:
532 base += f"_({subscript})"
533 if superscript:
534 base += f"^({superscript})"
535 return base
536
537
538 def _dominant_run_style(root: ET.Element) -> tuple[float, str, str]:
539 sizes: list[str] = []
540 colors: list[str] = []
541 languages: list[str] = []
542 for properties in root.iter(f"{_A}rPr"):
543 if properties.get("sz"):
544 sizes.append(properties.get("sz") or "")
545 if properties.get("lang"):
546 languages.append(properties.get("lang") or "")
547 color = properties.find(f"{_A}solidFill/{_A}srgbClr")
548 if color is not None and color.get("val"):
549 colors.append(color.get("val") or "")
550 raw_size = _dominant(sizes, "2100")
551 try:
552 font_size = max(1.0, min(400.0, int(raw_size) / 75.0))
553 except ValueError:
554 font_size = 28.0
555 return (
556 font_size,
557 f"#{_dominant(colors, '000000').upper()}",
558 _dominant(languages, "en-US"),
559 )
560
561
562 def _formula_alignment(root: ET.Element) -> str:
563 if root.tag != f"{_M}oMathPara":
564 return "left"
565 value = _property_value(root, "oMathParaPr", "jc", "center")
566 return {"left": "left", "right": "right"}.get(value, "center")
567
568
569 def _dominant(values: list[str], default: str) -> str:
570 return Counter(values).most_common(1)[0][0] if values else default
571
572
573 def _property_value(owner: ET.Element, properties: str, name: str, default: str) -> str:
574 container = owner.find(f"{_M}{properties}")
575 value = _property_child_value(container, name)
576 return value if value is not None else default
577
578
579 def _property_child_value(owner: ET.Element | None, name: str) -> str | None:
580 if owner is None:
581 return None
582 child = owner.find(f"{_M}{name}")
583 return child.get(f"{_M}val") if child is not None else None
584
585
586 def _on_property(owner: ET.Element | None, name: str) -> bool:
587 return _property_child_value(owner, name) in {"on", "true", "1"}
588
589
590 def _drawing_on(owner: ET.Element, name: str) -> bool:
591 return owner.get(name) in {"on", "true", "1"}
592
593
594 def _drawing_color(owner: ET.Element | None) -> str | None:
595 if owner is None:
596 return None
597 color = owner.find(f"{_A}solidFill/{_A}srgbClr")
598 return color.get("val") if color is not None else None
599
600
601 def _local_name(element: ET.Element) -> str:
602 return element.tag.rsplit("}", 1)[-1]
603
604
605 __all__ = [
606 "A14_NS",
607 "FormulaImport",
608 "FormulaImportError",
609 "import_formula",
610 "opaque_formula_preview",
611 ]
612
612 lines PYTHON