-
Notifications
You must be signed in to change notification settings - Fork 81
Expand file tree
/
Copy pathpptx_math.py
More file actions
322 lines (260 loc) · 11.7 KB
/
Copy pathpptx_math.py
File metadata and controls
322 lines (260 loc) · 11.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
from __future__ import annotations
"""
pptx_math
"""
version = "0.1"
import sys
from pathlib import Path
from lxml import etree
import globals
# ── Namespace URIs ────────────────────────────────────────────────────────────
A14 = "http://schemas.microsoft.com/office/drawing/2010/main"
M = "http://schemas.openxmlformats.org/officeDocument/2006/math"
A = "http://schemas.openxmlformats.org/drawingml/2006/main"
P = "http://schemas.openxmlformats.org/presentationml/2006/main"
MC = "http://schemas.openxmlformats.org/markup-compatibility/2006"
def _t(ns: str, local: str) -> str:
return f"{{{ns}}}{local}"
# ── LaTeX → OMML conversion ───────────────────────────────────────────────────
def _latex_to_omml(latex: str, transform: etree.XSLT) -> etree._Element:
import latex2mathml.converter
mathml_str = latex2mathml.converter.convert(latex)
mathml = etree.fromstring(mathml_str.encode())
result = transform(mathml)
root = result.getroot()
if root is None:
raise ValueError(f"XSLT produced no output for: {latex!r}")
return root
def _extract_oMath(elem: etree._Element) -> etree._Element:
if elem.tag == _t(M, "oMath"):
return elem
oMath = elem.find(_t(M, "oMath"))
if oMath is None:
raise ValueError(f"No <m:oMath> found in: {etree.tostring(elem)}")
return oMath
def _add_cambria_math_rpr(omath_elem: etree._Element) -> None:
"""Add <a:rPr><a:latin typeface="Cambria Math"> to each bare <m:r>.
Google Slides requires explicit Cambria Math declarations on math runs
to recognise and render OMML equations. MML2OMML.XSL does not add them.
"""
for mr in omath_elem.iter(_t(M, "r")):
if mr.find(_t(A, "rPr")) is None:
rPr = etree.Element(_t(A, "rPr"))
latin = etree.SubElement(rPr, _t(A, "latin"))
latin.set("typeface", "Cambria Math")
latin.set("panose", "02040503050406030204")
latin.set("pitchFamily","18")
latin.set("charset", "0")
mr.insert(0, rPr)
# ── Math injection ────────────────────────────────────────────────────────────
def inject_inline_math(p_elem: etree._Element, omath_elem: etree._Element) -> None:
"""Append <a14:m><m:oMath>...</m:oMath></a14:m> to a paragraph's lxml element."""
a14m = etree.Element(_t(A14, "m"), nsmap={"a14": A14})
a14m.append(omath_elem)
endParaRPr = p_elem.find(_t(A, "endParaRPr"))
if endParaRPr is not None:
endParaRPr.addprevious(a14m)
else:
p_elem.append(a14m)
def inject_block_math(p_elem: etree._Element, omath_elem: etree._Element) -> None:
"""Inject a centered block equation via <m:oMathPara centerGroup>."""
oMathPara = etree.Element(_t(M, "oMathPara"), nsmap={"m": M})
oMathParaPr = etree.SubElement(oMathPara, _t(M, "oMathParaPr"))
jc = etree.SubElement(oMathParaPr, _t(M, "jc"))
jc.set(_t(M, "val"), "centerGroup")
oMathPara.append(omath_elem)
a14m = etree.Element(_t(A14, "m"), nsmap={"a14": A14})
a14m.append(oMathPara)
endParaRPr = p_elem.find(_t(A, "endParaRPr"))
if endParaRPr is not None:
endParaRPr.addprevious(a14m)
else:
p_elem.append(a14m)
def inject_numbered_block_math(p_elem: etree._Element, omath_elem: etree._Element,
eq_number: str) -> None:
"""Inject a numbered block equation using DrawingML tab stops.
The paragraph pPr must already contain a centred tab at shape_width//2
and a right-aligned tab at shape_width (added by the caller). Content
layout: [tab→centre] [inline oMath] [tab→right] [(eq_number)]
"""
def _append(elem: etree._Element) -> None:
endParaRPr = p_elem.find(_t(A, "endParaRPr"))
if endParaRPr is not None:
endParaRPr.addprevious(elem)
else:
p_elem.append(elem)
# Leading tab: moves insertion point to the centre tab stop
r_tab1 = etree.Element(_t(A, "r"))
etree.SubElement(r_tab1, _t(A, "rPr"))
etree.SubElement(r_tab1, _t(A, "t")).text = "\t"
_append(r_tab1)
# Inline equation (no oMathPara wrapper so it stays on the same line)
a14m = etree.Element(_t(A14, "m"), nsmap={"a14": A14})
a14m.append(omath_elem)
_append(a14m)
# Trailing tab: moves insertion point to the right tab stop
r_tab2 = etree.Element(_t(A, "r"))
etree.SubElement(r_tab2, _t(A, "rPr"))
etree.SubElement(r_tab2, _t(A, "t")).text = "\t"
_append(r_tab2)
# Equation number text
r_num = etree.Element(_t(A, "r"))
etree.SubElement(r_num, _t(A, "rPr"))
etree.SubElement(r_num, _t(A, "t")).text = f"({eq_number})"
_append(r_num)
def _prep_math_shape(shape) -> None:
"""Remove txBox="1" and fix auto-resize on a math shape."""
sp = shape._element
tf = shape.text_frame
cNvSpPr = sp.find(f'.//{{{P}}}cNvSpPr')
if cNvSpPr is not None:
cNvSpPr.attrib.pop('txBox', None)
bodyPr = tf._txBody.bodyPr
bodyPr.attrib.pop("wrap", None)
for child in list(bodyPr):
if child.tag in (_t(A, "spAutoFit"), _t(A, "normAutofit")):
bodyPr.remove(child)
etree.SubElement(bodyPr, _t(A, "noAutofit"))
def _hoist_math_namespaces(shape) -> None:
"""Hoist xmlns:a14 and xmlns:mc to the <p:sp> element.
Google Slides' PPTX importer uses the presence of xmlns:a14 at the
shape level to identify OMML-containing shapes. lxml cannot add
namespace declarations to existing elements, so we serialise, patch
the opening tag, re-parse and replace in the slide tree.
Must be called AFTER all math content has been injected.
"""
sp = shape._element
parent = sp.getparent()
if parent is None or A14 in sp.nsmap.values():
return
xml = etree.tostring(sp, encoding="unicode")
xml = xml.replace(
"<p:sp ",
f'<p:sp xmlns:a14="{A14}" xmlns:mc="{MC}" ',
1,
)
new_sp = etree.fromstring(xml.encode())
idx = list(parent).index(sp)
parent.remove(sp)
parent.insert(idx, new_sp)
# ── MathInserter ──────────────────────────────────────────────────────────────
def _resolve_mathxsl_path(
configured_path: str | Path | None,
fallback_path: str | Path | None = None,
) -> Path:
"""Return the configured stylesheet or the copy beside md2pptx.
A user-supplied path takes precedence. If it is absent or no ``mathxsl``
option was supplied, look for ``mml2omml.xsl`` in the installation
directory.
"""
fallback = (
Path(fallback_path).expanduser()
if fallback_path is not None
else Path(__file__).resolve().with_name("mml2omml.xsl")
)
configured = Path(configured_path).expanduser() if configured_path else None
if configured is not None and configured.is_file():
return configured
if fallback.is_file():
return fallback
if configured is not None:
raise FileNotFoundError(
f"MML2OMML.XSL not found: {configured}; "
f"installation-directory fallback not found: {fallback}"
)
raise FileNotFoundError(
f"mathxsl option not set and installation-directory fallback "
f"not found: {fallback}"
)
class MathInserter:
def __init__(self, xsl_path: str | Path):
xsl_path = Path(xsl_path)
if not xsl_path.exists():
raise FileNotFoundError(f"MML2OMML.XSL not found: {xsl_path}")
self._xsl_path = xsl_path
try:
self._transform = etree.XSLT(etree.parse(str(xsl_path)))
except etree.XSLTParseError as e:
raise ValueError(
f"{xsl_path} is not a stylesheet libxslt can compile: {e}. "
f"Copies of MML2OMML.XSL differ; another copy may compile."
) from e
def make_inline_omml(self, latex: str) -> etree._Element:
omml = _latex_to_omml(latex, self._transform)
try:
omath = _extract_oMath(omml)
except ValueError as e:
raise ValueError(
f"{self._xsl_path} produced no OMML. It has to convert MathML "
f"into OMML, which is what MML2OMML.XSL does; a stylesheet "
f"converting the other way matches nothing here and yields an "
f"empty result. {e}"
) from e
_add_cambria_math_rpr(omath)
return omath
# ── PptxMath — block math handler (mirrors RunPython pattern) ─────────────────
class PptxMath:
def __init__(self):
pass
def run(self, prs, slide, renderingRectangle, codeLines, codeType):
try:
mathxsl_path = _resolve_mathxsl_path(
globals.processingOptions.getCurrentOption("mathxsl")
)
inserter = MathInserter(mathxsl_path)
except Exception as e:
sys.stderr.write(f"Math block skipped: {e}\n")
return
# Extract optional equation number: "math 1.2.4" → "1.2.4"
parts = codeType.split(None, 1)
eq_number = parts[1].strip() if len(parts) > 1 else None
latex = "\n".join(codeLines)
math_box = slide.shapes.add_textbox(
renderingRectangle.left,
renderingRectangle.top,
renderingRectangle.width,
renderingRectangle.height,
)
tf = math_box.text_frame
_prep_math_shape(math_box)
p = tf.paragraphs[0]
pPr = etree.Element(_t(A, "pPr"))
pPr.set("marL", "0")
pPr.set("indent", "0")
etree.SubElement(pPr, _t(A, "buNone"))
if eq_number:
# Centre tab at midpoint, right tab at shape right edge
w = renderingRectangle.width
tabLst = etree.SubElement(pPr, _t(A, "tabLst"))
tab_c = etree.SubElement(tabLst, _t(A, "tab"))
tab_c.set("pos", str(w // 2))
tab_c.set("algn", "ctr")
tab_r = etree.SubElement(tabLst, _t(A, "tab"))
tab_r.set("pos", str(w))
tab_r.set("algn", "r")
# Without this the equation inherits the text box default (18pt) and
# comes out smaller than the surrounding body text. Setting it on the
# paragraph's defRPr covers the math runs, the tabs and the equation
# number alike. defRPr must follow tabLst in the DrawingML sequence.
baseTextSize = globals.processingOptions.getCurrentOption("baseTextSize")
if baseTextSize > 0:
defRPr = etree.SubElement(pPr, _t(A, "defRPr"))
defRPr.set("sz", str(int(round(baseTextSize * 100))))
p._p.insert(0, pPr)
try:
omath = inserter.make_inline_omml(latex)
if eq_number:
inject_numbered_block_math(p._p, omath, eq_number)
else:
inject_block_math(p._p, omath)
except Exception as e:
# Name the formula: a deck can hold many math blocks and the
# literal LaTeX is what the reader will see left in the slide.
oneLine = " ".join(latex.split())
if len(oneLine) > 60:
oneLine = oneLine[:57] + "..."
sys.stderr.write(f"Math conversion failed for '{oneLine}': {e}\n")
p.text = latex
# Must be called after injection: serialises and re-parses the sp
# element to hoist xmlns:a14 / xmlns:mc to <p:sp> level.
_hoist_math_namespaces(math_box)