markdocx 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- markdocx/__init__.py +11 -0
- markdocx/cli.py +116 -0
- markdocx/code_renderer.py +164 -0
- markdocx/core.py +130 -0
- markdocx/docx_builder.py +743 -0
- markdocx/math_renderer.py +476 -0
- markdocx/md_parser.py +61 -0
- markdocx/styles.py +248 -0
- markdocx-0.1.0.dist-info/METADATA +182 -0
- markdocx-0.1.0.dist-info/RECORD +13 -0
- markdocx-0.1.0.dist-info/WHEEL +4 -0
- markdocx-0.1.0.dist-info/entry_points.txt +2 -0
- markdocx-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Math formula renderer.
|
|
3
|
+
Chuyển đổi công thức LaTeX thành OMML (Office Math Markup Language)
|
|
4
|
+
để hiển thị native trong Word. Sử dụng latex2mathml + XSLT.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import re
|
|
9
|
+
from lxml import etree
|
|
10
|
+
|
|
11
|
+
import latex2mathml.converter
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
# ── XSLT stylesheet to convert MathML → OMML ───────────────────────
|
|
16
|
+
# Microsoft's MML2OMML transformation (minimal embedded version)
|
|
17
|
+
# We load it once at module level for performance.
|
|
18
|
+
_OMML_NS = "http://schemas.openxmlformats.org/officeDocument/2006/math"
|
|
19
|
+
_MATHML_NS = "http://www.w3.org/1998/Math/MathML"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _clean_latex(latex_str):
|
|
23
|
+
"""
|
|
24
|
+
Clean/normalize LaTeX string for latex2mathml compatibility.
|
|
25
|
+
"""
|
|
26
|
+
s = latex_str.strip()
|
|
27
|
+
|
|
28
|
+
# Normalize common shortcuts
|
|
29
|
+
s = s.replace(r"\R", r"\mathbb{R}")
|
|
30
|
+
s = s.replace(r"\N", r"\mathbb{N}")
|
|
31
|
+
s = s.replace(r"\Z", r"\mathbb{Z}")
|
|
32
|
+
s = s.replace(r"\Q", r"\mathbb{Q}")
|
|
33
|
+
s = s.replace(r"\C", r"\mathbb{C}")
|
|
34
|
+
|
|
35
|
+
return s
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def latex_to_mathml(latex_str):
|
|
39
|
+
"""
|
|
40
|
+
Convert LaTeX math string to MathML XML string.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
latex_str: LaTeX math expression (without $ delimiters)
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
MathML XML string, or None on failure
|
|
47
|
+
"""
|
|
48
|
+
if not latex_str or not latex_str.strip():
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
cleaned = _clean_latex(latex_str)
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
mathml = latex2mathml.converter.convert(cleaned)
|
|
55
|
+
return mathml
|
|
56
|
+
except Exception as e:
|
|
57
|
+
logger.warning(f"latex2mathml failed for: {latex_str!r} → {e}")
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _mathml_to_omml(mathml_str):
|
|
62
|
+
"""
|
|
63
|
+
Convert MathML XML string to OMML (Office Math Markup Language) element.
|
|
64
|
+
Uses a direct conversion approach since lxml XSLT with Microsoft's
|
|
65
|
+
MML2OMML.xsl requires the full file.
|
|
66
|
+
|
|
67
|
+
We use a simplified but robust recursive converter.
|
|
68
|
+
"""
|
|
69
|
+
try:
|
|
70
|
+
# Parse the MathML
|
|
71
|
+
# latex2mathml output may have namespace declaration
|
|
72
|
+
if 'xmlns' not in mathml_str:
|
|
73
|
+
mathml_str = mathml_str.replace('<math>', f'<math xmlns="{_MATHML_NS}">')
|
|
74
|
+
|
|
75
|
+
root = etree.fromstring(mathml_str.encode('utf-8'))
|
|
76
|
+
# Build OMML
|
|
77
|
+
omml = _convert_mathml_element(root)
|
|
78
|
+
return omml
|
|
79
|
+
except Exception as e:
|
|
80
|
+
logger.warning(f"MathML→OMML conversion failed: {e}")
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _make_omml(tag, text=None):
|
|
85
|
+
"""Create an OMML element with optional text."""
|
|
86
|
+
el = etree.SubElement(etree.Element("dummy"), f"{{{_OMML_NS}}}{tag}")
|
|
87
|
+
el = etree.Element(f"{{{_OMML_NS}}}{tag}")
|
|
88
|
+
if text is not None:
|
|
89
|
+
el.text = text
|
|
90
|
+
return el
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _omml_run(text):
|
|
94
|
+
"""Create an OMML run <m:r> containing <m:t>text</m:t>."""
|
|
95
|
+
r = etree.Element(f"{{{_OMML_NS}}}r")
|
|
96
|
+
t = etree.SubElement(r, f"{{{_OMML_NS}}}t")
|
|
97
|
+
t.text = text
|
|
98
|
+
return r
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _local(tag):
|
|
102
|
+
"""Strip namespace from tag."""
|
|
103
|
+
if '}' in tag:
|
|
104
|
+
return tag.split('}', 1)[1]
|
|
105
|
+
return tag
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _convert_mathml_element(elem):
|
|
109
|
+
"""
|
|
110
|
+
Recursively convert a MathML element tree to OMML element tree.
|
|
111
|
+
Supports: mrow, mi, mn, mo, mfrac, msqrt, mroot, msup, msub,
|
|
112
|
+
msubsup, mover, munder, munderover, mtable, mtr, mtd, mtext,
|
|
113
|
+
mfenced (pmatrix, bmatrix, cases, vmatrix...), mspace.
|
|
114
|
+
"""
|
|
115
|
+
tag = _local(elem.tag)
|
|
116
|
+
|
|
117
|
+
if tag == "math":
|
|
118
|
+
omath = etree.Element(f"{{{_OMML_NS}}}oMath")
|
|
119
|
+
for child in elem:
|
|
120
|
+
result = _convert_mathml_element(child)
|
|
121
|
+
if result is not None:
|
|
122
|
+
omath.append(result)
|
|
123
|
+
return omath
|
|
124
|
+
|
|
125
|
+
elif tag == "mrow":
|
|
126
|
+
# Group — just convert children sequentially
|
|
127
|
+
group = etree.Element(f"{{{_OMML_NS}}}oMath")
|
|
128
|
+
for child in elem:
|
|
129
|
+
result = _convert_mathml_element(child)
|
|
130
|
+
if result is not None:
|
|
131
|
+
group.append(result)
|
|
132
|
+
# If wrapping in oMath is redundant, return children directly
|
|
133
|
+
# We'll use a dummy wrapper and extract later
|
|
134
|
+
return group
|
|
135
|
+
|
|
136
|
+
elif tag in ("mi", "mn", "mo", "mtext"):
|
|
137
|
+
text = (elem.text or "").strip()
|
|
138
|
+
if not text:
|
|
139
|
+
return None
|
|
140
|
+
r = etree.Element(f"{{{_OMML_NS}}}r")
|
|
141
|
+
# Mark identifier styling
|
|
142
|
+
if tag == "mi" and len(text) == 1:
|
|
143
|
+
rPr = etree.SubElement(r, f"{{{_OMML_NS}}}rPr")
|
|
144
|
+
sty = etree.SubElement(rPr, f"{{{_OMML_NS}}}sty")
|
|
145
|
+
sty.set(f"{{{_OMML_NS}}}val", "p" if text.isdigit() else "i")
|
|
146
|
+
t = etree.SubElement(r, f"{{{_OMML_NS}}}t")
|
|
147
|
+
t.text = text
|
|
148
|
+
return r
|
|
149
|
+
|
|
150
|
+
elif tag == "mfrac":
|
|
151
|
+
children = list(elem)
|
|
152
|
+
if len(children) < 2:
|
|
153
|
+
return None
|
|
154
|
+
f = etree.Element(f"{{{_OMML_NS}}}f")
|
|
155
|
+
fPr = etree.SubElement(f, f"{{{_OMML_NS}}}fPr")
|
|
156
|
+
# Check for line (displaystyle fraction vs inline)
|
|
157
|
+
if elem.get("linethickness") == "0":
|
|
158
|
+
type_el = etree.SubElement(fPr, f"{{{_OMML_NS}}}type")
|
|
159
|
+
type_el.set(f"{{{_OMML_NS}}}val", "noBar")
|
|
160
|
+
|
|
161
|
+
num = etree.SubElement(f, f"{{{_OMML_NS}}}num")
|
|
162
|
+
num_content = _convert_mathml_element(children[0])
|
|
163
|
+
if num_content is not None:
|
|
164
|
+
num.append(num_content)
|
|
165
|
+
|
|
166
|
+
den = etree.SubElement(f, f"{{{_OMML_NS}}}den")
|
|
167
|
+
den_content = _convert_mathml_element(children[1])
|
|
168
|
+
if den_content is not None:
|
|
169
|
+
den.append(den_content)
|
|
170
|
+
return f
|
|
171
|
+
|
|
172
|
+
elif tag == "msqrt":
|
|
173
|
+
rad = etree.Element(f"{{{_OMML_NS}}}rad")
|
|
174
|
+
radPr = etree.SubElement(rad, f"{{{_OMML_NS}}}radPr")
|
|
175
|
+
degHide = etree.SubElement(radPr, f"{{{_OMML_NS}}}degHide")
|
|
176
|
+
degHide.set(f"{{{_OMML_NS}}}val", "1")
|
|
177
|
+
deg = etree.SubElement(rad, f"{{{_OMML_NS}}}deg")
|
|
178
|
+
e_elem = etree.SubElement(rad, f"{{{_OMML_NS}}}e")
|
|
179
|
+
for child in elem:
|
|
180
|
+
result = _convert_mathml_element(child)
|
|
181
|
+
if result is not None:
|
|
182
|
+
e_elem.append(result)
|
|
183
|
+
return rad
|
|
184
|
+
|
|
185
|
+
elif tag == "mroot":
|
|
186
|
+
children = list(elem)
|
|
187
|
+
rad = etree.Element(f"{{{_OMML_NS}}}rad")
|
|
188
|
+
radPr = etree.SubElement(rad, f"{{{_OMML_NS}}}radPr")
|
|
189
|
+
deg = etree.SubElement(rad, f"{{{_OMML_NS}}}deg")
|
|
190
|
+
if len(children) > 1:
|
|
191
|
+
deg_content = _convert_mathml_element(children[1])
|
|
192
|
+
if deg_content is not None:
|
|
193
|
+
deg.append(deg_content)
|
|
194
|
+
e_elem = etree.SubElement(rad, f"{{{_OMML_NS}}}e")
|
|
195
|
+
if children:
|
|
196
|
+
base_content = _convert_mathml_element(children[0])
|
|
197
|
+
if base_content is not None:
|
|
198
|
+
e_elem.append(base_content)
|
|
199
|
+
return rad
|
|
200
|
+
|
|
201
|
+
elif tag == "msup":
|
|
202
|
+
children = list(elem)
|
|
203
|
+
if len(children) < 2:
|
|
204
|
+
return None
|
|
205
|
+
sSup = etree.Element(f"{{{_OMML_NS}}}sSup")
|
|
206
|
+
e_elem = etree.SubElement(sSup, f"{{{_OMML_NS}}}e")
|
|
207
|
+
base = _convert_mathml_element(children[0])
|
|
208
|
+
if base is not None:
|
|
209
|
+
e_elem.append(base)
|
|
210
|
+
sup = etree.SubElement(sSup, f"{{{_OMML_NS}}}sup")
|
|
211
|
+
sup_content = _convert_mathml_element(children[1])
|
|
212
|
+
if sup_content is not None:
|
|
213
|
+
sup.append(sup_content)
|
|
214
|
+
return sSup
|
|
215
|
+
|
|
216
|
+
elif tag == "msub":
|
|
217
|
+
children = list(elem)
|
|
218
|
+
if len(children) < 2:
|
|
219
|
+
return None
|
|
220
|
+
sSub = etree.Element(f"{{{_OMML_NS}}}sSub")
|
|
221
|
+
e_elem = etree.SubElement(sSub, f"{{{_OMML_NS}}}e")
|
|
222
|
+
base = _convert_mathml_element(children[0])
|
|
223
|
+
if base is not None:
|
|
224
|
+
e_elem.append(base)
|
|
225
|
+
sub = etree.SubElement(sSub, f"{{{_OMML_NS}}}sub")
|
|
226
|
+
sub_content = _convert_mathml_element(children[1])
|
|
227
|
+
if sub_content is not None:
|
|
228
|
+
sub.append(sub_content)
|
|
229
|
+
return sSub
|
|
230
|
+
|
|
231
|
+
elif tag == "msubsup":
|
|
232
|
+
children = list(elem)
|
|
233
|
+
if len(children) < 3:
|
|
234
|
+
return None
|
|
235
|
+
sSubSup = etree.Element(f"{{{_OMML_NS}}}sSubSup")
|
|
236
|
+
e_elem = etree.SubElement(sSubSup, f"{{{_OMML_NS}}}e")
|
|
237
|
+
base = _convert_mathml_element(children[0])
|
|
238
|
+
if base is not None:
|
|
239
|
+
e_elem.append(base)
|
|
240
|
+
sub = etree.SubElement(sSubSup, f"{{{_OMML_NS}}}sub")
|
|
241
|
+
sub_c = _convert_mathml_element(children[1])
|
|
242
|
+
if sub_c is not None:
|
|
243
|
+
sub.append(sub_c)
|
|
244
|
+
sup = etree.SubElement(sSubSup, f"{{{_OMML_NS}}}sup")
|
|
245
|
+
sup_c = _convert_mathml_element(children[2])
|
|
246
|
+
if sup_c is not None:
|
|
247
|
+
sup.append(sup_c)
|
|
248
|
+
return sSubSup
|
|
249
|
+
|
|
250
|
+
elif tag == "mover":
|
|
251
|
+
children = list(elem)
|
|
252
|
+
if len(children) < 2:
|
|
253
|
+
return None
|
|
254
|
+
# Check if the "over" is an accent (hat, bar, vec, etc.)
|
|
255
|
+
acc = etree.Element(f"{{{_OMML_NS}}}acc")
|
|
256
|
+
accPr = etree.SubElement(acc, f"{{{_OMML_NS}}}accPr")
|
|
257
|
+
# Get the accent character
|
|
258
|
+
over_text = _get_text(children[1])
|
|
259
|
+
if over_text:
|
|
260
|
+
chr_el = etree.SubElement(accPr, f"{{{_OMML_NS}}}chr")
|
|
261
|
+
chr_el.set(f"{{{_OMML_NS}}}val", over_text)
|
|
262
|
+
e_elem = etree.SubElement(acc, f"{{{_OMML_NS}}}e")
|
|
263
|
+
base = _convert_mathml_element(children[0])
|
|
264
|
+
if base is not None:
|
|
265
|
+
e_elem.append(base)
|
|
266
|
+
return acc
|
|
267
|
+
|
|
268
|
+
elif tag == "munder":
|
|
269
|
+
children = list(elem)
|
|
270
|
+
if len(children) < 2:
|
|
271
|
+
return None
|
|
272
|
+
# Use limLow for underscript
|
|
273
|
+
limLow = etree.Element(f"{{{_OMML_NS}}}limLow")
|
|
274
|
+
e_elem = etree.SubElement(limLow, f"{{{_OMML_NS}}}e")
|
|
275
|
+
base = _convert_mathml_element(children[0])
|
|
276
|
+
if base is not None:
|
|
277
|
+
e_elem.append(base)
|
|
278
|
+
lim = etree.SubElement(limLow, f"{{{_OMML_NS}}}lim")
|
|
279
|
+
lim_c = _convert_mathml_element(children[1])
|
|
280
|
+
if lim_c is not None:
|
|
281
|
+
lim.append(lim_c)
|
|
282
|
+
return limLow
|
|
283
|
+
|
|
284
|
+
elif tag == "munderover":
|
|
285
|
+
children = list(elem)
|
|
286
|
+
if len(children) < 3:
|
|
287
|
+
return None
|
|
288
|
+
# nary (summation, product, integral, etc.) or limLow+sup
|
|
289
|
+
base_text = _get_text(children[0])
|
|
290
|
+
nary_chars = {"∑", "∏", "∫", "∬", "∭", "⋃", "⋂", "⋁", "⋀"}
|
|
291
|
+
if base_text in nary_chars:
|
|
292
|
+
nary = etree.Element(f"{{{_OMML_NS}}}nary")
|
|
293
|
+
naryPr = etree.SubElement(nary, f"{{{_OMML_NS}}}naryPr")
|
|
294
|
+
chr_el = etree.SubElement(naryPr, f"{{{_OMML_NS}}}chr")
|
|
295
|
+
chr_el.set(f"{{{_OMML_NS}}}val", base_text)
|
|
296
|
+
sub = etree.SubElement(nary, f"{{{_OMML_NS}}}sub")
|
|
297
|
+
sub_c = _convert_mathml_element(children[1])
|
|
298
|
+
if sub_c is not None:
|
|
299
|
+
sub.append(sub_c)
|
|
300
|
+
sup = etree.SubElement(nary, f"{{{_OMML_NS}}}sup")
|
|
301
|
+
sup_c = _convert_mathml_element(children[2])
|
|
302
|
+
if sup_c is not None:
|
|
303
|
+
sup.append(sup_c)
|
|
304
|
+
e_elem = etree.SubElement(nary, f"{{{_OMML_NS}}}e")
|
|
305
|
+
return nary
|
|
306
|
+
else:
|
|
307
|
+
# Generic: use limLow then wrap in sSup
|
|
308
|
+
limLow = etree.Element(f"{{{_OMML_NS}}}limLow")
|
|
309
|
+
e_elem = etree.SubElement(limLow, f"{{{_OMML_NS}}}e")
|
|
310
|
+
base = _convert_mathml_element(children[0])
|
|
311
|
+
if base is not None:
|
|
312
|
+
e_elem.append(base)
|
|
313
|
+
lim = etree.SubElement(limLow, f"{{{_OMML_NS}}}lim")
|
|
314
|
+
lim_c = _convert_mathml_element(children[1])
|
|
315
|
+
if lim_c is not None:
|
|
316
|
+
lim.append(lim_c)
|
|
317
|
+
# Wrap in sSup for the overscript
|
|
318
|
+
sSup = etree.Element(f"{{{_OMML_NS}}}sSup")
|
|
319
|
+
e2 = etree.SubElement(sSup, f"{{{_OMML_NS}}}e")
|
|
320
|
+
e2.append(limLow)
|
|
321
|
+
sup = etree.SubElement(sSup, f"{{{_OMML_NS}}}sup")
|
|
322
|
+
sup_c = _convert_mathml_element(children[2])
|
|
323
|
+
if sup_c is not None:
|
|
324
|
+
sup.append(sup_c)
|
|
325
|
+
return sSup
|
|
326
|
+
|
|
327
|
+
elif tag == "mfenced":
|
|
328
|
+
# Delimiters: parentheses, brackets, braces, cases, etc.
|
|
329
|
+
open_delim = elem.get("open", "(")
|
|
330
|
+
close_delim = elem.get("close", ")")
|
|
331
|
+
separators = elem.get("separators", ",")
|
|
332
|
+
|
|
333
|
+
d = etree.Element(f"{{{_OMML_NS}}}d")
|
|
334
|
+
dPr = etree.SubElement(d, f"{{{_OMML_NS}}}dPr")
|
|
335
|
+
if open_delim:
|
|
336
|
+
begChr = etree.SubElement(dPr, f"{{{_OMML_NS}}}begChr")
|
|
337
|
+
begChr.set(f"{{{_OMML_NS}}}val", open_delim)
|
|
338
|
+
if close_delim:
|
|
339
|
+
endChr = etree.SubElement(dPr, f"{{{_OMML_NS}}}endChr")
|
|
340
|
+
endChr.set(f"{{{_OMML_NS}}}val", close_delim)
|
|
341
|
+
if separators:
|
|
342
|
+
sepChr = etree.SubElement(dPr, f"{{{_OMML_NS}}}sepChr")
|
|
343
|
+
sepChr.set(f"{{{_OMML_NS}}}val", separators.strip()[0] if separators.strip() else "")
|
|
344
|
+
|
|
345
|
+
for child in elem:
|
|
346
|
+
e_elem = etree.SubElement(d, f"{{{_OMML_NS}}}e")
|
|
347
|
+
result = _convert_mathml_element(child)
|
|
348
|
+
if result is not None:
|
|
349
|
+
e_elem.append(result)
|
|
350
|
+
return d
|
|
351
|
+
|
|
352
|
+
elif tag == "mtable":
|
|
353
|
+
# Matrix/table
|
|
354
|
+
m = etree.Element(f"{{{_OMML_NS}}}m")
|
|
355
|
+
mPr = etree.SubElement(m, f"{{{_OMML_NS}}}mPr")
|
|
356
|
+
# Count columns from first row
|
|
357
|
+
first_row = elem.find(f"{{{_MATHML_NS}}}mtr")
|
|
358
|
+
if first_row is None:
|
|
359
|
+
first_row = elem.find("mtr")
|
|
360
|
+
if first_row is not None:
|
|
361
|
+
num_cols = len(list(first_row))
|
|
362
|
+
mcs = etree.SubElement(mPr, f"{{{_OMML_NS}}}mcs")
|
|
363
|
+
mc = etree.SubElement(mcs, f"{{{_OMML_NS}}}mc")
|
|
364
|
+
mcPr = etree.SubElement(mc, f"{{{_OMML_NS}}}mcPr")
|
|
365
|
+
count = etree.SubElement(mcPr, f"{{{_OMML_NS}}}count")
|
|
366
|
+
count.set(f"{{{_OMML_NS}}}val", str(num_cols))
|
|
367
|
+
mcJc = etree.SubElement(mcPr, f"{{{_OMML_NS}}}mcJc")
|
|
368
|
+
mcJc.set(f"{{{_OMML_NS}}}val", "center")
|
|
369
|
+
|
|
370
|
+
for row_elem in elem:
|
|
371
|
+
if _local(row_elem.tag) == "mtr":
|
|
372
|
+
mr = etree.SubElement(m, f"{{{_OMML_NS}}}mr")
|
|
373
|
+
for cell_elem in row_elem:
|
|
374
|
+
if _local(cell_elem.tag) == "mtd":
|
|
375
|
+
e_elem = etree.SubElement(mr, f"{{{_OMML_NS}}}e")
|
|
376
|
+
for cell_child in cell_elem:
|
|
377
|
+
result = _convert_mathml_element(cell_child)
|
|
378
|
+
if result is not None:
|
|
379
|
+
e_elem.append(result)
|
|
380
|
+
# If mtd has direct text
|
|
381
|
+
if cell_elem.text and cell_elem.text.strip():
|
|
382
|
+
e_elem.append(_omml_run(cell_elem.text.strip()))
|
|
383
|
+
return m
|
|
384
|
+
|
|
385
|
+
elif tag == "mspace":
|
|
386
|
+
return _omml_run("\u2003") # em space
|
|
387
|
+
|
|
388
|
+
elif tag == "mpadded":
|
|
389
|
+
# Just process children
|
|
390
|
+
group = etree.Element(f"{{{_OMML_NS}}}oMath")
|
|
391
|
+
for child in elem:
|
|
392
|
+
result = _convert_mathml_element(child)
|
|
393
|
+
if result is not None:
|
|
394
|
+
group.append(result)
|
|
395
|
+
return group
|
|
396
|
+
|
|
397
|
+
elif tag == "mstyle":
|
|
398
|
+
# Just process children (ignore style attrs for now)
|
|
399
|
+
group = etree.Element(f"{{{_OMML_NS}}}oMath")
|
|
400
|
+
for child in elem:
|
|
401
|
+
result = _convert_mathml_element(child)
|
|
402
|
+
if result is not None:
|
|
403
|
+
group.append(result)
|
|
404
|
+
return group
|
|
405
|
+
|
|
406
|
+
elif tag == "menclose":
|
|
407
|
+
# Treat as grouping
|
|
408
|
+
group = etree.Element(f"{{{_OMML_NS}}}oMath")
|
|
409
|
+
for child in elem:
|
|
410
|
+
result = _convert_mathml_element(child)
|
|
411
|
+
if result is not None:
|
|
412
|
+
group.append(result)
|
|
413
|
+
return group
|
|
414
|
+
|
|
415
|
+
else:
|
|
416
|
+
# Unknown tag — try to process children or text
|
|
417
|
+
if elem.text and elem.text.strip():
|
|
418
|
+
return _omml_run(elem.text.strip())
|
|
419
|
+
group = etree.Element(f"{{{_OMML_NS}}}oMath")
|
|
420
|
+
for child in elem:
|
|
421
|
+
result = _convert_mathml_element(child)
|
|
422
|
+
if result is not None:
|
|
423
|
+
group.append(result)
|
|
424
|
+
if len(group):
|
|
425
|
+
return group
|
|
426
|
+
return None
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _get_text(elem):
|
|
430
|
+
"""Get all text content from a MathML element recursively."""
|
|
431
|
+
texts = []
|
|
432
|
+
if elem.text:
|
|
433
|
+
texts.append(elem.text)
|
|
434
|
+
for child in elem:
|
|
435
|
+
texts.append(_get_text(child))
|
|
436
|
+
if child.tail:
|
|
437
|
+
texts.append(child.tail)
|
|
438
|
+
return "".join(texts).strip()
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def latex_to_omml(latex_str):
|
|
442
|
+
"""
|
|
443
|
+
Convert LaTeX math to an OMML element that can be inserted into a DOCX paragraph.
|
|
444
|
+
|
|
445
|
+
Args:
|
|
446
|
+
latex_str: LaTeX math expression (without $ delimiters)
|
|
447
|
+
|
|
448
|
+
Returns:
|
|
449
|
+
lxml Element (OMML oMath), or None on failure
|
|
450
|
+
"""
|
|
451
|
+
mathml = latex_to_mathml(latex_str)
|
|
452
|
+
if mathml is None:
|
|
453
|
+
return None
|
|
454
|
+
|
|
455
|
+
omml = _mathml_to_omml(mathml)
|
|
456
|
+
return omml
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def latex_to_omml_para(latex_str):
|
|
460
|
+
"""
|
|
461
|
+
Convert LaTeX math to an OMML oMathPara element (for display/block math).
|
|
462
|
+
|
|
463
|
+
Args:
|
|
464
|
+
latex_str: LaTeX math expression
|
|
465
|
+
|
|
466
|
+
Returns:
|
|
467
|
+
lxml Element (OMML oMathPara), or None on failure
|
|
468
|
+
"""
|
|
469
|
+
omath = latex_to_omml(latex_str)
|
|
470
|
+
if omath is None:
|
|
471
|
+
return None
|
|
472
|
+
|
|
473
|
+
# Wrap in oMathPara for block display
|
|
474
|
+
oMathPara = etree.Element(f"{{{_OMML_NS}}}oMathPara")
|
|
475
|
+
oMathPara.append(omath)
|
|
476
|
+
return oMathPara
|
markdocx/md_parser.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Markdown parser using markdown-it-py.
|
|
3
|
+
Phân tích cú pháp Markdown thành token stream.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from markdown_it import MarkdownIt
|
|
7
|
+
from mdit_py_plugins.dollarmath import dollarmath_plugin
|
|
8
|
+
from mdit_py_plugins.footnote import footnote_plugin
|
|
9
|
+
from mdit_py_plugins.front_matter import front_matter_plugin
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def create_parser():
|
|
13
|
+
"""
|
|
14
|
+
Create a configured markdown-it-py parser with all necessary plugins.
|
|
15
|
+
Supports: tables, strikethrough, math ($...$, $$...$$), footnotes.
|
|
16
|
+
"""
|
|
17
|
+
md = MarkdownIt("commonmark", {"breaks": True, "html": True})
|
|
18
|
+
|
|
19
|
+
# Enable built-in extensions
|
|
20
|
+
md.enable("table")
|
|
21
|
+
md.enable("strikethrough")
|
|
22
|
+
|
|
23
|
+
# Enable math support: $inline$ and $$display$$
|
|
24
|
+
md.use(dollarmath_plugin, double_inline=True)
|
|
25
|
+
|
|
26
|
+
# Enable footnotes
|
|
27
|
+
md.use(footnote_plugin)
|
|
28
|
+
|
|
29
|
+
# Enable front matter (YAML)
|
|
30
|
+
md.use(front_matter_plugin)
|
|
31
|
+
|
|
32
|
+
return md
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def parse_markdown(text):
|
|
36
|
+
"""
|
|
37
|
+
Parse markdown text into a list of tokens.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
text: Raw markdown string
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
List of markdown-it Token objects
|
|
44
|
+
"""
|
|
45
|
+
md = create_parser()
|
|
46
|
+
tokens = md.parse(text)
|
|
47
|
+
return tokens
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def render_html(text):
|
|
51
|
+
"""
|
|
52
|
+
Render markdown text to HTML (for debugging/preview).
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
text: Raw markdown string
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
HTML string
|
|
59
|
+
"""
|
|
60
|
+
md = create_parser()
|
|
61
|
+
return md.render(text)
|