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.
@@ -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)