@cliven/mddocx 1.0.1
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.
- package/.claude-plugin/plugin.json +8 -0
- package/.codex-plugin/plugin.json +16 -0
- package/.cursor-plugin/plugin.json +9 -0
- package/.opencode/INSTALL.md +31 -0
- package/.opencode/plugins/mddocx.js +41 -0
- package/LICENSE +21 -0
- package/README.md +95 -0
- package/bin/mddocx.js +29 -0
- package/hooks/hooks.json +12 -0
- package/hooks/session-start +29 -0
- package/package.json +25 -0
- package/skills/mddoc/SKILL.md +493 -0
- package/skills/mddoc/evals/evals.json +23 -0
- package/skills/mddoc/evals/test-sample.md +67 -0
- package/skills/mddoc/scripts/__pycache__/md2docx.cpython-312.pyc +0 -0
- package/skills/mddoc/scripts/md2docx.py +1200 -0
|
@@ -0,0 +1,1200 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
mddoc - Markdown 转学术格式 DOCX
|
|
4
|
+
|
|
5
|
+
用法:
|
|
6
|
+
python md2docx.py <input.md> # 转换 Markdown 文件
|
|
7
|
+
python md2docx.py <input.md> -o out.docx # 指定输出路径
|
|
8
|
+
python md2docx.py --text "markdown..." # 直接转换文本
|
|
9
|
+
|
|
10
|
+
依赖:
|
|
11
|
+
pip install python-docx Pillow requests mistune
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import sys
|
|
18
|
+
import tempfile
|
|
19
|
+
import urllib.parse
|
|
20
|
+
from io import BytesIO
|
|
21
|
+
|
|
22
|
+
from docx import Document
|
|
23
|
+
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
|
24
|
+
from docx.oxml import OxmlElement
|
|
25
|
+
from docx.oxml.ns import qn
|
|
26
|
+
from docx.shared import Cm, Pt
|
|
27
|
+
from lxml import etree
|
|
28
|
+
from PIL import Image
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ============================================================
|
|
32
|
+
# OMML 公式工具 (LaTeX → OMML XML)
|
|
33
|
+
# ============================================================
|
|
34
|
+
|
|
35
|
+
# OMML 命名空间
|
|
36
|
+
NSM = 'http://schemas.openxmlformats.org/officeDocument/2006/math'
|
|
37
|
+
|
|
38
|
+
def _m_qn(local):
|
|
39
|
+
"""构造 m 命名空间的 Clark 格式属性名,如 _m_qn('val') → '{NSM}val'"""
|
|
40
|
+
return f'{{{NSM}}}{local}'
|
|
41
|
+
|
|
42
|
+
# LaTeX 希腊字母 → Unicode 映射
|
|
43
|
+
GREEK = {
|
|
44
|
+
'alpha': 'α', 'beta': 'β', 'gamma': 'γ', 'delta': 'δ',
|
|
45
|
+
'epsilon': 'ε', 'zeta': 'ζ', 'eta': 'η', 'theta': 'θ',
|
|
46
|
+
'iota': 'ι', 'kappa': 'κ', 'lambda': 'λ', 'mu': 'μ',
|
|
47
|
+
'nu': 'ν', 'xi': 'ξ', 'omicron': 'ο', 'pi': 'π',
|
|
48
|
+
'rho': 'ρ', 'sigma': 'σ', 'tau': 'τ', 'upsilon': 'υ',
|
|
49
|
+
'phi': 'φ', 'chi': 'χ', 'psi': 'ψ', 'omega': 'ω',
|
|
50
|
+
'Gamma': 'Γ', 'Delta': 'Δ', 'Theta': 'Θ', 'Lambda': 'Λ',
|
|
51
|
+
'Xi': 'Ξ', 'Pi': 'Π', 'Sigma': 'Σ', 'Upsilon': 'Υ',
|
|
52
|
+
'Phi': 'Φ', 'Psi': 'Ψ', 'Omega': 'Ω',
|
|
53
|
+
'varepsilon': 'ϵ', 'varphi': 'ϕ', 'vartheta': 'ϑ',
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# LaTeX 符号 → Unicode
|
|
57
|
+
SYMBOLS = {
|
|
58
|
+
'infty': '∞', 'cdot': '·', 'times': '×', 'pm': '±', 'mp': '∓',
|
|
59
|
+
'leq': '≤', 'geq': '≥', 'neq': '≠', 'approx': '≈', 'equiv': '≡',
|
|
60
|
+
'propto': '∝', 'sim': '∼', 'simeq': '≃',
|
|
61
|
+
'partial': '∂', 'nabla': '∇', 'forall': '∀', 'exists': '∃',
|
|
62
|
+
'in': '∈', 'notin': '∉', 'subset': '⊂', 'supset': '⊃',
|
|
63
|
+
'subseteq': '⊆', 'supseteq': '⊇', 'cap': '∩', 'cup': '∪',
|
|
64
|
+
'rightarrow': '→', 'leftarrow': '←', 'Rightarrow': '⇒', 'Leftarrow': '⇐',
|
|
65
|
+
'leftrightarrow': '↔', 'mapsto': '↦',
|
|
66
|
+
'ldots': '…', 'cdots': '⋯', 'vdots': '⋮', 'ddots': '⋱',
|
|
67
|
+
'circ': '∘', 'bullet': '•', 'oplus': '⊕', 'otimes': '⊗',
|
|
68
|
+
'angle': '∠', 'triangle': '△', 'square': '□',
|
|
69
|
+
'mid': '|', 'parallel': '∥', 'perp': '⊥',
|
|
70
|
+
'aleph': 'ℵ', 'hbar': 'ℏ', 'imath': 'ı', 'jmath': 'ȷ',
|
|
71
|
+
'ell': 'ℓ', 'wp': '℘', 'Re': 'ℜ', 'Im': 'ℑ',
|
|
72
|
+
'prime': '′', 'emptyset': '∅', 'varnothing': '∅',
|
|
73
|
+
'langle': '⟨', 'rangle': '⟩', 'lceil': '⌈', 'rceil': '⌉',
|
|
74
|
+
'lfloor': '⌊', 'rfloor': '⌋',
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
# 需要特殊处理的函数名
|
|
78
|
+
FUNCTIONS = {
|
|
79
|
+
'sin', 'cos', 'tan', 'cot', 'sec', 'csc',
|
|
80
|
+
'arcsin', 'arccos', 'arctan',
|
|
81
|
+
'sinh', 'cosh', 'tanh', 'coth',
|
|
82
|
+
'log', 'lg', 'ln', 'exp',
|
|
83
|
+
'lim', 'max', 'min', 'sup', 'inf',
|
|
84
|
+
'det', 'dim', 'ker', 'gcd', 'deg', 'hom',
|
|
85
|
+
'arg', 'Pr',
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
BIG_OPS = {
|
|
89
|
+
'sum': '∑', 'prod': '∏', 'coprod': '∐',
|
|
90
|
+
'int': '∫', 'iint': '∬', 'iiint': '∭', 'oint': '∮',
|
|
91
|
+
'bigcup': '⋃', 'bigcap': '⋂', 'bigvee': '⋁', 'bigwedge': '⋀',
|
|
92
|
+
'bigoplus': '⨁', 'bigotimes': '⨂', 'bigodot': '⨀',
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
ACCENTS = {
|
|
96
|
+
'hat': '̂', 'bar': '̅', 'vec': '⃗', 'dot': '̇',
|
|
97
|
+
'ddot': '̈', 'tilde': '̃', 'breve': '̆', 'check': '̌',
|
|
98
|
+
'acute': '́', 'grave': '̀',
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _m_elem(tag):
|
|
103
|
+
"""创建带 OMML 命名空间的 XML 元素"""
|
|
104
|
+
return etree.Element(f'{{{NSM}}}{tag}', nsmap={'m': NSM})
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _m_run(text, italic=True):
|
|
108
|
+
"""创建 m:r 元素,包含 m:t 文本子元素"""
|
|
109
|
+
r = _m_elem('r')
|
|
110
|
+
if not italic:
|
|
111
|
+
rPr = _m_elem('rPr')
|
|
112
|
+
nor = _m_elem('nor')
|
|
113
|
+
nor.set(_m_qn('val'), '1')
|
|
114
|
+
rPr.append(nor)
|
|
115
|
+
r.append(rPr)
|
|
116
|
+
t_elem = _m_elem('t')
|
|
117
|
+
t_elem.set(qn('xml:space'), 'preserve')
|
|
118
|
+
t_elem.text = text
|
|
119
|
+
r.append(t_elem)
|
|
120
|
+
return r
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _m_text_run(text):
|
|
124
|
+
"""创建非斜体文本 run(用于 \text{}、函数名等)"""
|
|
125
|
+
return _m_run(text, italic=False)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _m_wrap_d(children):
|
|
129
|
+
"""将子元素列表包装在 m:d(分隔符)元素中"""
|
|
130
|
+
d = _m_elem('d')
|
|
131
|
+
for c in children:
|
|
132
|
+
if isinstance(c, str):
|
|
133
|
+
d.append(_m_run(c))
|
|
134
|
+
elif c is not None:
|
|
135
|
+
d.append(c)
|
|
136
|
+
return d
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class _LatexParser:
|
|
140
|
+
"""LaTeX 数学公式递归下降解析器,生成 OMML XML"""
|
|
141
|
+
|
|
142
|
+
def __init__(self, latex):
|
|
143
|
+
self.s = latex
|
|
144
|
+
self.pos = 0
|
|
145
|
+
|
|
146
|
+
def peek(self):
|
|
147
|
+
if self.pos < len(self.s):
|
|
148
|
+
return self.s[self.pos]
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
def consume(self):
|
|
152
|
+
c = self.peek()
|
|
153
|
+
if c is not None:
|
|
154
|
+
self.pos += 1
|
|
155
|
+
return c
|
|
156
|
+
|
|
157
|
+
def expect(self, expected):
|
|
158
|
+
if self.peek() != expected:
|
|
159
|
+
return False
|
|
160
|
+
self.consume()
|
|
161
|
+
return True
|
|
162
|
+
|
|
163
|
+
def skip_spaces(self):
|
|
164
|
+
while self.peek() in (' ', '\t', '\n'):
|
|
165
|
+
self.consume()
|
|
166
|
+
|
|
167
|
+
def read_name(self):
|
|
168
|
+
"""读取反斜杠后的命令名,如 \frac → 'frac'"""
|
|
169
|
+
name = []
|
|
170
|
+
while self.peek() and self.peek().isalpha():
|
|
171
|
+
name.append(self.consume())
|
|
172
|
+
return ''.join(name)
|
|
173
|
+
|
|
174
|
+
def read_required_arg(self):
|
|
175
|
+
"""读取花括号中的必选参数 { ... },注意嵌套"""
|
|
176
|
+
self.skip_spaces()
|
|
177
|
+
if not self.expect('{'):
|
|
178
|
+
return None
|
|
179
|
+
depth = 1
|
|
180
|
+
buf = []
|
|
181
|
+
while self.pos < len(self.s) and depth > 0:
|
|
182
|
+
c = self.consume()
|
|
183
|
+
if c == '{':
|
|
184
|
+
depth += 1
|
|
185
|
+
elif c == '}':
|
|
186
|
+
depth -= 1
|
|
187
|
+
if depth == 0:
|
|
188
|
+
break
|
|
189
|
+
buf.append(c)
|
|
190
|
+
return ''.join(buf)
|
|
191
|
+
|
|
192
|
+
def parse_expr(self):
|
|
193
|
+
"""解析表达式:term*"""
|
|
194
|
+
children = []
|
|
195
|
+
while self.pos < len(self.s):
|
|
196
|
+
c = self.peek()
|
|
197
|
+
if c is None or c == '}':
|
|
198
|
+
break
|
|
199
|
+
if c == '&': # 矩阵列分隔
|
|
200
|
+
self.consume()
|
|
201
|
+
children.append(_m_run('&'))
|
|
202
|
+
continue
|
|
203
|
+
if c == '\\\\': # 矩阵行分隔
|
|
204
|
+
self.consume()
|
|
205
|
+
self.consume()
|
|
206
|
+
children.append(_m_run('\\\\'))
|
|
207
|
+
continue
|
|
208
|
+
term = self.parse_term()
|
|
209
|
+
if term is not None:
|
|
210
|
+
children.append(term)
|
|
211
|
+
if self.peek() == '}':
|
|
212
|
+
break
|
|
213
|
+
return children
|
|
214
|
+
|
|
215
|
+
def parse_term(self):
|
|
216
|
+
"""解析单个 term:atom 后可选 sub/sup"""
|
|
217
|
+
c = self.peek()
|
|
218
|
+
if c is None or c == '}':
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
# 上标 ^
|
|
222
|
+
if c == '^':
|
|
223
|
+
self.consume()
|
|
224
|
+
sup_elem = self.parse_atom()
|
|
225
|
+
if sup_elem is not None:
|
|
226
|
+
sSup = _m_elem('sSup')
|
|
227
|
+
sSup_e = _m_elem('e')
|
|
228
|
+
sSup_e.append(_m_run('')) # 空基数
|
|
229
|
+
sSup.append(sSup_e)
|
|
230
|
+
sSup_sup = _m_elem('sup')
|
|
231
|
+
sSup_sup.append(sup_elem if not isinstance(sup_elem, str) else _m_run(sup_elem))
|
|
232
|
+
sSup.append(sSup_sup)
|
|
233
|
+
return sSup # Don't try to attach sub/sup further
|
|
234
|
+
else:
|
|
235
|
+
return _m_run('^')
|
|
236
|
+
|
|
237
|
+
# 下标 _
|
|
238
|
+
if c == '_':
|
|
239
|
+
self.consume()
|
|
240
|
+
sub_elem = self.parse_atom()
|
|
241
|
+
if sub_elem is not None:
|
|
242
|
+
sSub = _m_elem('sSub')
|
|
243
|
+
sSub_e = _m_elem('e')
|
|
244
|
+
sSub_e.append(_m_run(''))
|
|
245
|
+
sSub.append(sSub_e)
|
|
246
|
+
sSub_sub = _m_elem('sub')
|
|
247
|
+
sSub_sub.append(sub_elem if not isinstance(sub_elem, str) else _m_run(sub_elem))
|
|
248
|
+
sSub.append(sSub_sub)
|
|
249
|
+
return sSub
|
|
250
|
+
else:
|
|
251
|
+
return _m_run('_')
|
|
252
|
+
|
|
253
|
+
# atom
|
|
254
|
+
atom = self.parse_atom()
|
|
255
|
+
if atom is None:
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
# 检查后续的 sub/sup
|
|
259
|
+
c = self.peek()
|
|
260
|
+
|
|
261
|
+
# _ 后接 ^ (如 x_i^2 或 \sum_{i=1}^{n})
|
|
262
|
+
if c == '_':
|
|
263
|
+
self.consume()
|
|
264
|
+
sub_arg = self.parse_atom()
|
|
265
|
+
sup_arg = None
|
|
266
|
+
if self.peek() == '^':
|
|
267
|
+
self.consume()
|
|
268
|
+
sup_arg = self.parse_atom()
|
|
269
|
+
|
|
270
|
+
if sup_arg is not None:
|
|
271
|
+
sSubSup = _m_elem('sSubSup')
|
|
272
|
+
e = _m_elem('e')
|
|
273
|
+
e.append(atom if not isinstance(atom, str) else _m_run(atom))
|
|
274
|
+
sSubSup.append(e)
|
|
275
|
+
s = _m_elem('sub')
|
|
276
|
+
s.append(sub_arg if not isinstance(sub_arg, str) else _m_run(sub_arg))
|
|
277
|
+
sSubSup.append(s)
|
|
278
|
+
sup = _m_elem('sup')
|
|
279
|
+
sup.append(sup_arg if not isinstance(sup_arg, str) else _m_run(sup_arg))
|
|
280
|
+
sSubSup.append(sup)
|
|
281
|
+
return sSubSup
|
|
282
|
+
else:
|
|
283
|
+
sSub = _m_elem('sSub')
|
|
284
|
+
e = _m_elem('e')
|
|
285
|
+
e.append(atom if not isinstance(atom, str) else _m_run(atom))
|
|
286
|
+
sSub.append(e)
|
|
287
|
+
s = _m_elem('sub')
|
|
288
|
+
s.append(sub_arg if not isinstance(sub_arg, str) else _m_run(sub_arg))
|
|
289
|
+
sSub.append(s)
|
|
290
|
+
return sSub
|
|
291
|
+
|
|
292
|
+
elif c == '^':
|
|
293
|
+
self.consume()
|
|
294
|
+
sup_arg = self.parse_atom()
|
|
295
|
+
if sup_arg is not None:
|
|
296
|
+
sSup = _m_elem('sSup')
|
|
297
|
+
e = _m_elem('e')
|
|
298
|
+
e.append(atom if not isinstance(atom, str) else _m_run(atom))
|
|
299
|
+
sSup.append(e)
|
|
300
|
+
sup = _m_elem('sup')
|
|
301
|
+
sup.append(sup_arg if not isinstance(sup_arg, str) else _m_run(sup_arg))
|
|
302
|
+
sSup.append(sup)
|
|
303
|
+
return sSup
|
|
304
|
+
|
|
305
|
+
return atom
|
|
306
|
+
|
|
307
|
+
def parse_atom(self):
|
|
308
|
+
"""解析最小单元:字母/数字、命令、花括号组"""
|
|
309
|
+
c = self.peek()
|
|
310
|
+
if c is None or c == '}' or c == '&':
|
|
311
|
+
return None
|
|
312
|
+
|
|
313
|
+
# 花括号组 { ... }
|
|
314
|
+
if c == '{':
|
|
315
|
+
self.consume()
|
|
316
|
+
children = self.parse_expr()
|
|
317
|
+
self.expect('}') # consume closing brace if present
|
|
318
|
+
if len(children) == 1:
|
|
319
|
+
return children[0]
|
|
320
|
+
return _m_wrap_d(children)
|
|
321
|
+
|
|
322
|
+
# LaTeX 命令 \
|
|
323
|
+
if c == '\\':
|
|
324
|
+
self.consume()
|
|
325
|
+
if self.peek() is None:
|
|
326
|
+
return _m_run('\\')
|
|
327
|
+
|
|
328
|
+
# 检查是否为空
|
|
329
|
+
if self.peek() in (' ', '\t', '\n'):
|
|
330
|
+
return _m_run(' ')
|
|
331
|
+
|
|
332
|
+
name = self.read_name()
|
|
333
|
+
if not name:
|
|
334
|
+
# 转义字符如 \$ \# \% \& \_ \{ \}
|
|
335
|
+
esc = self.consume()
|
|
336
|
+
if esc:
|
|
337
|
+
return _m_run(esc)
|
|
338
|
+
return _m_run('\\')
|
|
339
|
+
|
|
340
|
+
# n-ary 大运算符(sum, prod, int 等)
|
|
341
|
+
if name in BIG_OPS:
|
|
342
|
+
nary = _m_elem('nary')
|
|
343
|
+
naryPr = _m_elem('naryPr')
|
|
344
|
+
chr_elem = _m_elem('chr')
|
|
345
|
+
chr_elem.set(_m_qn('val'), BIG_OPS[name])
|
|
346
|
+
naryPr.append(chr_elem)
|
|
347
|
+
limLoc = _m_elem('limLoc')
|
|
348
|
+
limLoc.set(_m_qn('val'), 'undOvr') # limits above and below
|
|
349
|
+
naryPr.append(limLoc)
|
|
350
|
+
nary.append(naryPr)
|
|
351
|
+
|
|
352
|
+
# 读取下上限
|
|
353
|
+
if self.peek() == '_':
|
|
354
|
+
self.consume()
|
|
355
|
+
sub_arg = self.parse_atom()
|
|
356
|
+
if sub_arg is not None:
|
|
357
|
+
sub = _m_elem('sub')
|
|
358
|
+
sub.append(sub_arg if not isinstance(sub_arg, str) else _m_run(sub_arg))
|
|
359
|
+
nary.append(sub)
|
|
360
|
+
|
|
361
|
+
if self.peek() == '^':
|
|
362
|
+
self.consume()
|
|
363
|
+
sup_arg = self.parse_atom()
|
|
364
|
+
if sup_arg is not None:
|
|
365
|
+
sup = _m_elem('sup')
|
|
366
|
+
sup.append(sup_arg if not isinstance(sup_arg, str) else _m_run(sup_arg))
|
|
367
|
+
nary.append(sup)
|
|
368
|
+
|
|
369
|
+
e = _m_elem('e')
|
|
370
|
+
# 读取 integrand / summand
|
|
371
|
+
rest = self.parse_expr()
|
|
372
|
+
if rest:
|
|
373
|
+
for r in rest:
|
|
374
|
+
e.append(r if not isinstance(r, str) else _m_run(r))
|
|
375
|
+
nary.append(e)
|
|
376
|
+
return nary
|
|
377
|
+
|
|
378
|
+
# 分数 \frac{num}{den}
|
|
379
|
+
if name == 'frac':
|
|
380
|
+
num_text = self.read_required_arg()
|
|
381
|
+
den_text = self.read_required_arg()
|
|
382
|
+
if num_text is not None and den_text is not None:
|
|
383
|
+
f = _m_elem('f')
|
|
384
|
+
num = _m_elem('num')
|
|
385
|
+
num_parsed = _LatexParser(num_text).parse_expr()
|
|
386
|
+
for item in num_parsed:
|
|
387
|
+
num.append(item if not isinstance(item, str) else _m_run(item))
|
|
388
|
+
f.append(num)
|
|
389
|
+
den = _m_elem('den')
|
|
390
|
+
den_parsed = _LatexParser(den_text).parse_expr()
|
|
391
|
+
for item in den_parsed:
|
|
392
|
+
den.append(item if not isinstance(item, str) else _m_run(item))
|
|
393
|
+
f.append(den)
|
|
394
|
+
return f
|
|
395
|
+
return _m_run(f'\\frac{{{num_text}}}{{{den_text}}}')
|
|
396
|
+
|
|
397
|
+
# 平方根 \sqrt{...} 或 \sqrt[n]{...}
|
|
398
|
+
if name == 'sqrt':
|
|
399
|
+
rad = _m_elem('rad')
|
|
400
|
+
deg = None
|
|
401
|
+
if self.peek() == '[':
|
|
402
|
+
self.consume()
|
|
403
|
+
deg_text = []
|
|
404
|
+
while self.peek() and self.peek() != ']':
|
|
405
|
+
deg_text.append(self.consume())
|
|
406
|
+
self.expect(']')
|
|
407
|
+
deg_text = ''.join(deg_text)
|
|
408
|
+
deg = _m_elem('deg')
|
|
409
|
+
deg.append(_m_run(deg_text))
|
|
410
|
+
if deg is not None:
|
|
411
|
+
rad.append(deg)
|
|
412
|
+
e = _m_elem('e')
|
|
413
|
+
body = self.read_required_arg()
|
|
414
|
+
if body is not None:
|
|
415
|
+
body_parsed = _LatexParser(body).parse_expr()
|
|
416
|
+
for item in body_parsed:
|
|
417
|
+
e.append(item if not isinstance(item, str) else _m_run(item))
|
|
418
|
+
rad.append(e)
|
|
419
|
+
return rad
|
|
420
|
+
|
|
421
|
+
# 函数名 \sin, \cos, \log 等
|
|
422
|
+
if name in FUNCTIONS:
|
|
423
|
+
return _m_run(name)
|
|
424
|
+
|
|
425
|
+
# 文本 \text{...}
|
|
426
|
+
if name == 'text':
|
|
427
|
+
txt = self.read_required_arg()
|
|
428
|
+
return _m_text_run(txt) if txt else _m_run('\\text{}')
|
|
429
|
+
|
|
430
|
+
# 重音符 \hat, \bar, \vec 等
|
|
431
|
+
if name in ACCENTS:
|
|
432
|
+
acc = _m_elem('acc')
|
|
433
|
+
accPr = _m_elem('accPr')
|
|
434
|
+
chr_elem = _m_elem('chr')
|
|
435
|
+
chr_elem.set(_m_qn('val'), ACCENTS[name])
|
|
436
|
+
accPr.append(chr_elem)
|
|
437
|
+
acc.append(accPr)
|
|
438
|
+
e = _m_elem('e')
|
|
439
|
+
# 重音符可以跟花括号参数或单个字符
|
|
440
|
+
base = self.parse_atom()
|
|
441
|
+
if base is not None:
|
|
442
|
+
e.append(base if not isinstance(base, str) else _m_run(base))
|
|
443
|
+
acc.append(e)
|
|
444
|
+
return acc
|
|
445
|
+
|
|
446
|
+
# 定界符 \left( \right) \left[ \right] 等
|
|
447
|
+
if name == 'left':
|
|
448
|
+
delim_char = self.consume()
|
|
449
|
+
# 暂时用 d 包装,标记左定界符
|
|
450
|
+
d = _m_elem('d')
|
|
451
|
+
dPr = _m_elem('dPr')
|
|
452
|
+
begChr = _m_elem('begChr')
|
|
453
|
+
begChr.set(_m_qn('val'), delim_char or '(')
|
|
454
|
+
dPr.append(begChr)
|
|
455
|
+
d.append(dPr)
|
|
456
|
+
# 读取直到 \right
|
|
457
|
+
body = []
|
|
458
|
+
while self.pos < len(self.s):
|
|
459
|
+
if self.peek() == '\\':
|
|
460
|
+
saved = self.pos
|
|
461
|
+
self.consume()
|
|
462
|
+
name2 = self.read_name()
|
|
463
|
+
if name2 == 'right':
|
|
464
|
+
right_delim = self.consume()
|
|
465
|
+
endChr = _m_elem('endChr')
|
|
466
|
+
endChr.set(_m_qn('val'), right_delim or ')')
|
|
467
|
+
dPr.append(endChr)
|
|
468
|
+
break
|
|
469
|
+
else:
|
|
470
|
+
# 不是 \right,回退
|
|
471
|
+
self.pos = saved
|
|
472
|
+
body.append(self.parse_term())
|
|
473
|
+
else:
|
|
474
|
+
body.append(self.parse_term())
|
|
475
|
+
for b in body:
|
|
476
|
+
if b is not None:
|
|
477
|
+
d.append(b if not isinstance(b, str) else _m_run(b))
|
|
478
|
+
return d
|
|
479
|
+
|
|
480
|
+
# 花括号(literal braces via \{ \})
|
|
481
|
+
if name == '{':
|
|
482
|
+
return _m_run('{')
|
|
483
|
+
if name == '}':
|
|
484
|
+
return _m_run('}')
|
|
485
|
+
|
|
486
|
+
# 希腊字母
|
|
487
|
+
if name in GREEK:
|
|
488
|
+
return _m_run(GREEK[name])
|
|
489
|
+
|
|
490
|
+
# 符号
|
|
491
|
+
if name in SYMBOLS:
|
|
492
|
+
return _m_run(SYMBOLS[name])
|
|
493
|
+
|
|
494
|
+
# 未知命令:保留原始文本
|
|
495
|
+
result = ['\\' + name]
|
|
496
|
+
if self.peek() == '{':
|
|
497
|
+
result.append('{' + (self.read_required_arg() or '') + '}')
|
|
498
|
+
return _m_run(''.join(result))
|
|
499
|
+
|
|
500
|
+
# 普通字符
|
|
501
|
+
ch = self.consume()
|
|
502
|
+
return _m_run(ch)
|
|
503
|
+
|
|
504
|
+
def parse(self):
|
|
505
|
+
"""主入口:解析整个 LaTeX 字符串并返回 OMML 元素列表"""
|
|
506
|
+
self.skip_spaces()
|
|
507
|
+
return self.parse_expr()
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def latex_to_omml(latex, display=False):
|
|
511
|
+
"""将 LaTeX 数学公式转换为 OMML XML 元素
|
|
512
|
+
|
|
513
|
+
参数:
|
|
514
|
+
latex: LaTeX 公式字符串(不含 $ 定界符)
|
|
515
|
+
display: True=行间公式(m:oMathPara), False=行内公式(m:oMath)
|
|
516
|
+
返回:
|
|
517
|
+
OMML 顶级元素(m:oMath 或 m:oMathPara),可直接插入段落 XML
|
|
518
|
+
"""
|
|
519
|
+
parser = _LatexParser(latex)
|
|
520
|
+
children = parser.parse()
|
|
521
|
+
|
|
522
|
+
omml = _m_elem('oMath')
|
|
523
|
+
for c in children:
|
|
524
|
+
omml.append(c if not isinstance(c, str) else _m_run(c))
|
|
525
|
+
|
|
526
|
+
if display:
|
|
527
|
+
omp = _m_elem('oMathPara')
|
|
528
|
+
omp.append(omml)
|
|
529
|
+
return omp
|
|
530
|
+
|
|
531
|
+
return omml
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
# ============================================================
|
|
535
|
+
# 字体工具
|
|
536
|
+
# ============================================================
|
|
537
|
+
|
|
538
|
+
def set_run_font(run, cn_font, en_font="Times New Roman", size_pt=10.5, bold=False):
|
|
539
|
+
"""设置 run 的中英文字体、字号、加粗"""
|
|
540
|
+
run.font.size = Pt(size_pt)
|
|
541
|
+
run.font.bold = bold
|
|
542
|
+
run.font.name = en_font
|
|
543
|
+
run.font.color.rgb = None # 黑色
|
|
544
|
+
rPr = run._element.get_or_add_rPr()
|
|
545
|
+
rFonts = rPr.find(qn('w:rFonts'))
|
|
546
|
+
if rFonts is None:
|
|
547
|
+
rFonts = OxmlElement('w:rFonts')
|
|
548
|
+
rPr.insert(0, rFonts)
|
|
549
|
+
rFonts.set(qn('w:eastAsia'), cn_font)
|
|
550
|
+
rFonts.set(qn('w:ascii'), en_font)
|
|
551
|
+
rFonts.set(qn('w:hAnsi'), en_font)
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def add_empty_para(doc):
|
|
555
|
+
"""添加五号空行"""
|
|
556
|
+
p = doc.add_paragraph()
|
|
557
|
+
run = p.add_run('')
|
|
558
|
+
set_run_font(run, '宋体', size_pt=10.5)
|
|
559
|
+
p.paragraph_format.space_before = Pt(0)
|
|
560
|
+
p.paragraph_format.space_after = Pt(0)
|
|
561
|
+
return p
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
# ============================================================
|
|
565
|
+
# 图片工具
|
|
566
|
+
# ============================================================
|
|
567
|
+
|
|
568
|
+
def download_image(url):
|
|
569
|
+
"""下载图片到临时文件,返回路径。失败返回 None"""
|
|
570
|
+
import requests
|
|
571
|
+
headers = {'User-Agent': 'Mozilla/5.0 (compatible; mddoc-converter/1.0)'}
|
|
572
|
+
try:
|
|
573
|
+
resp = requests.get(url, headers=headers, timeout=30)
|
|
574
|
+
resp.raise_for_status()
|
|
575
|
+
suffix = os.path.splitext(urllib.parse.urlparse(url).path)[1] or '.png'
|
|
576
|
+
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
|
|
577
|
+
tmp.write(resp.content)
|
|
578
|
+
tmp.close()
|
|
579
|
+
return tmp.name
|
|
580
|
+
except Exception:
|
|
581
|
+
return None
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def calc_image_size(img_path):
|
|
585
|
+
"""计算图片在 docx 中的显示尺寸:8-12cm 规则,等比缩放"""
|
|
586
|
+
img = Image.open(img_path)
|
|
587
|
+
dpi = img.info.get('dpi', (96, 96))
|
|
588
|
+
dpi_x = dpi[0] if dpi and dpi[0] > 0 else 96
|
|
589
|
+
dpi_y = dpi[1] if dpi and dpi[1] > 0 else 96
|
|
590
|
+
|
|
591
|
+
width_cm = img.width / dpi_x * 2.54
|
|
592
|
+
height_cm = img.height / dpi_y * 2.54
|
|
593
|
+
|
|
594
|
+
if width_cm > 12:
|
|
595
|
+
target_w = 12.0
|
|
596
|
+
elif width_cm < 8:
|
|
597
|
+
target_w = 8.0
|
|
598
|
+
else:
|
|
599
|
+
target_w = width_cm
|
|
600
|
+
|
|
601
|
+
ratio = target_w / width_cm
|
|
602
|
+
return Cm(target_w), Cm(height_cm * ratio)
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def add_placeholder_image(doc, text):
|
|
606
|
+
"""生成占位图片:灰色矩形 + 文字描述"""
|
|
607
|
+
from PIL import ImageDraw, ImageFont
|
|
608
|
+
img = Image.new('RGB', (600, 200), (220, 220, 220))
|
|
609
|
+
draw = ImageDraw.Draw(img)
|
|
610
|
+
draw.text((10, 80), text, fill=(80, 80, 80))
|
|
611
|
+
tmp = tempfile.NamedTemporaryFile(delete=False, suffix='.png')
|
|
612
|
+
img.save(tmp, format='PNG')
|
|
613
|
+
tmp.close()
|
|
614
|
+
return tmp.name
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
# ============================================================
|
|
618
|
+
# 表格工具
|
|
619
|
+
# ============================================================
|
|
620
|
+
|
|
621
|
+
def set_three_line_table(table):
|
|
622
|
+
"""三线表:仅顶线粗+底线粗,表头格底线细,数据行之间无线,无竖线"""
|
|
623
|
+
tbl = table._tbl
|
|
624
|
+
tblPr = tbl.tblPr
|
|
625
|
+
if tblPr is None:
|
|
626
|
+
tblPr = OxmlElement('w:tblPr')
|
|
627
|
+
tbl.insert(0, tblPr)
|
|
628
|
+
|
|
629
|
+
borders = OxmlElement('w:tblBorders')
|
|
630
|
+
|
|
631
|
+
# 顶线:粗 1.5pt
|
|
632
|
+
top = OxmlElement('w:top')
|
|
633
|
+
top.set(qn('w:val'), 'single')
|
|
634
|
+
top.set(qn('w:sz'), '12')
|
|
635
|
+
top.set(qn('w:space'), '0')
|
|
636
|
+
top.set(qn('w:color'), '000000')
|
|
637
|
+
borders.append(top)
|
|
638
|
+
|
|
639
|
+
# 底线:粗 1.5pt
|
|
640
|
+
bottom = OxmlElement('w:bottom')
|
|
641
|
+
bottom.set(qn('w:val'), 'single')
|
|
642
|
+
bottom.set(qn('w:sz'), '12')
|
|
643
|
+
bottom.set(qn('w:space'), '0')
|
|
644
|
+
bottom.set(qn('w:color'), '000000')
|
|
645
|
+
borders.append(bottom)
|
|
646
|
+
|
|
647
|
+
# 所有内部线+竖线全部关闭(不用insideH!数据行之间无线)
|
|
648
|
+
for edge in ('left', 'right', 'insideH', 'insideV'):
|
|
649
|
+
elem = OxmlElement(f'w:{edge}')
|
|
650
|
+
elem.set(qn('w:val'), 'none')
|
|
651
|
+
elem.set(qn('w:sz'), '0')
|
|
652
|
+
elem.set(qn('w:space'), '0')
|
|
653
|
+
elem.set(qn('w:color'), 'auto')
|
|
654
|
+
borders.append(elem)
|
|
655
|
+
|
|
656
|
+
tblPr.append(borders)
|
|
657
|
+
|
|
658
|
+
# 表头行每格底部单独加细线 0.75pt(cell级border,非table级insideH)
|
|
659
|
+
if table.rows:
|
|
660
|
+
for cell in table.rows[0].cells:
|
|
661
|
+
tcPr = cell._tc.get_or_add_tcPr()
|
|
662
|
+
tcBorders = OxmlElement('w:tcBorders')
|
|
663
|
+
cell_bottom = OxmlElement('w:bottom')
|
|
664
|
+
cell_bottom.set(qn('w:val'), 'single')
|
|
665
|
+
cell_bottom.set(qn('w:sz'), '6')
|
|
666
|
+
cell_bottom.set(qn('w:space'), '0')
|
|
667
|
+
cell_bottom.set(qn('w:color'), '000000')
|
|
668
|
+
tcBorders.append(cell_bottom)
|
|
669
|
+
tcPr.append(tcBorders)
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def add_page_break_before(paragraph):
|
|
673
|
+
"""在段落前插入分页符"""
|
|
674
|
+
pPr = paragraph._element.get_or_add_pPr()
|
|
675
|
+
pb = OxmlElement('w:pageBreakBefore')
|
|
676
|
+
pPr.append(pb)
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def set_outline_level(paragraph, level):
|
|
680
|
+
"""设置 outline level(XML方式,兼容所有python-docx版本)"""
|
|
681
|
+
pPr = paragraph._element.get_or_add_pPr()
|
|
682
|
+
ol = OxmlElement('w:outlineLvl')
|
|
683
|
+
ol.set(qn('w:val'), str(level))
|
|
684
|
+
pPr.append(ol)
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
# ============================================================
|
|
688
|
+
# Markdown 解析
|
|
689
|
+
# ============================================================
|
|
690
|
+
|
|
691
|
+
def split_inline_math(text):
|
|
692
|
+
r"""分割段落文本中的行内公式 $...$,返回 segment 列表。
|
|
693
|
+
每个 segment: {'type': 'text'|'math', 'content': str}
|
|
694
|
+
支持 \$ 转义,不支持嵌套。
|
|
695
|
+
"""
|
|
696
|
+
if not text:
|
|
697
|
+
return [{'type': 'text', 'content': text}]
|
|
698
|
+
segments = []
|
|
699
|
+
# 匹配 $...$ 但不匹配 $$
|
|
700
|
+
pattern = re.compile(r'(?<!\\)\$([^$]+?)(?<!\\)\$')
|
|
701
|
+
last_end = 0
|
|
702
|
+
for m in pattern.finditer(text):
|
|
703
|
+
if m.start() > last_end:
|
|
704
|
+
segments.append({'type': 'text', 'content': text[last_end:m.start()]})
|
|
705
|
+
segments.append({'type': 'math', 'content': m.group(1)})
|
|
706
|
+
last_end = m.end()
|
|
707
|
+
if last_end < len(text):
|
|
708
|
+
segments.append({'type': 'text', 'content': text[last_end:]})
|
|
709
|
+
return segments if segments else [{'type': 'text', 'content': text}]
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def parse_markdown(text):
|
|
713
|
+
"""解析 Markdown 文本,返回节点列表。
|
|
714
|
+
每个节点: {'type': 'title'|'heading'|'para'|'image'|'table'|'code'|'list',
|
|
715
|
+
'level': int (仅heading),
|
|
716
|
+
'text': str,
|
|
717
|
+
'children': [...] (仅list),
|
|
718
|
+
'alt': str (仅image),
|
|
719
|
+
'url': str (仅image),
|
|
720
|
+
'header': list (仅table),
|
|
721
|
+
'rows': list (仅table)}
|
|
722
|
+
"""
|
|
723
|
+
import mistune
|
|
724
|
+
|
|
725
|
+
nodes = []
|
|
726
|
+
lines = text.split('\n')
|
|
727
|
+
i = 0
|
|
728
|
+
heading_count = 0 # 跟踪 # 标题数量,第一个是题目
|
|
729
|
+
|
|
730
|
+
while i < len(lines):
|
|
731
|
+
line = lines[i]
|
|
732
|
+
|
|
733
|
+
# 代码块
|
|
734
|
+
if line.strip().startswith('```'):
|
|
735
|
+
code_lines = []
|
|
736
|
+
i += 1
|
|
737
|
+
while i < len(lines) and not lines[i].strip().startswith('```'):
|
|
738
|
+
code_lines.append(lines[i])
|
|
739
|
+
i += 1
|
|
740
|
+
i += 1
|
|
741
|
+
nodes.append({'type': 'code', 'text': '\n'.join(code_lines)})
|
|
742
|
+
continue
|
|
743
|
+
|
|
744
|
+
# 行间公式 $$ ... $$(同行或跨行)
|
|
745
|
+
if line.strip().startswith('$$'):
|
|
746
|
+
stripped = line.strip()
|
|
747
|
+
# 同行闭合:$$ ... $$
|
|
748
|
+
if stripped.count('$$') >= 2:
|
|
749
|
+
inner = re.search(r'\$\$(.+?)\$\$', stripped)
|
|
750
|
+
if inner:
|
|
751
|
+
nodes.append({'type': 'display_math', 'text': inner.group(1).strip()})
|
|
752
|
+
i += 1
|
|
753
|
+
continue
|
|
754
|
+
# 跨行:$$ 独占一行开始
|
|
755
|
+
math_lines = []
|
|
756
|
+
i += 1
|
|
757
|
+
while i < len(lines) and not lines[i].strip().startswith('$$'):
|
|
758
|
+
math_lines.append(lines[i])
|
|
759
|
+
i += 1
|
|
760
|
+
i += 1 # skip closing $$
|
|
761
|
+
nodes.append({'type': 'display_math', 'text': '\n'.join(math_lines).strip()})
|
|
762
|
+
continue
|
|
763
|
+
|
|
764
|
+
# 标题 (# ...)
|
|
765
|
+
m = re.match(r'^(#{1,6})\s+(.*)', line)
|
|
766
|
+
if m:
|
|
767
|
+
level = len(m.group(1))
|
|
768
|
+
title_text = m.group(2).strip()
|
|
769
|
+
heading_count += 1
|
|
770
|
+
if level == 1 and heading_count == 1:
|
|
771
|
+
nodes.append({'type': 'title', 'text': title_text, 'level': 1})
|
|
772
|
+
else:
|
|
773
|
+
nodes.append({'type': 'heading', 'text': title_text, 'level': level})
|
|
774
|
+
i += 1
|
|
775
|
+
continue
|
|
776
|
+
|
|
777
|
+
# 图片 ()
|
|
778
|
+
m = re.match(r'^!\[([^\]]*)\]\(([^)]+)\)', line.strip())
|
|
779
|
+
if m:
|
|
780
|
+
nodes.append({'type': 'image', 'alt': m.group(1), 'url': m.group(2)})
|
|
781
|
+
i += 1
|
|
782
|
+
continue
|
|
783
|
+
|
|
784
|
+
# 表格(检测 | 开头的行)
|
|
785
|
+
if line.strip().startswith('|'):
|
|
786
|
+
table_lines = []
|
|
787
|
+
while i < len(lines) and lines[i].strip().startswith('|'):
|
|
788
|
+
table_lines.append(lines[i].strip())
|
|
789
|
+
i += 1
|
|
790
|
+
# 解析表格:跳过第二行(分隔符行)
|
|
791
|
+
header = [c.strip() for c in table_lines[0].split('|')[1:-1]]
|
|
792
|
+
rows = []
|
|
793
|
+
for tl in table_lines[2:]:
|
|
794
|
+
rows.append([c.strip() for c in tl.split('|')[1:-1]])
|
|
795
|
+
nodes.append({'type': 'table', 'header': header, 'rows': rows})
|
|
796
|
+
continue
|
|
797
|
+
|
|
798
|
+
# 空行
|
|
799
|
+
if not line.strip():
|
|
800
|
+
nodes.append({'type': 'empty'})
|
|
801
|
+
i += 1
|
|
802
|
+
continue
|
|
803
|
+
|
|
804
|
+
# 无序列表
|
|
805
|
+
m = re.match(r'^(\s*)[-*+]\s+(.*)', line)
|
|
806
|
+
if m:
|
|
807
|
+
list_items = []
|
|
808
|
+
indent = len(m.group(1))
|
|
809
|
+
while i < len(lines):
|
|
810
|
+
lm = re.match(r'^(\s*)[-*+]\s+(.*)', lines[i])
|
|
811
|
+
if not lm:
|
|
812
|
+
break
|
|
813
|
+
if abs(len(lm.group(1)) - indent) > 2 and list_items:
|
|
814
|
+
break
|
|
815
|
+
list_items.append(lm.group(2).strip())
|
|
816
|
+
indent = len(lm.group(1))
|
|
817
|
+
i += 1
|
|
818
|
+
nodes.append({'type': 'list', 'children': list_items})
|
|
819
|
+
continue
|
|
820
|
+
|
|
821
|
+
# 普通段落
|
|
822
|
+
text = line.strip()
|
|
823
|
+
nodes.append({'type': 'para', 'text': text, 'segments': split_inline_math(text)})
|
|
824
|
+
i += 1
|
|
825
|
+
|
|
826
|
+
return nodes
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
# ============================================================
|
|
830
|
+
# DOCX 生成
|
|
831
|
+
# ============================================================
|
|
832
|
+
|
|
833
|
+
def extract_title_text(title_node):
|
|
834
|
+
"""从题目节点提取纯标题文字(去除'第一章'等前缀)"""
|
|
835
|
+
text = title_node['text']
|
|
836
|
+
# 去除中文序号前缀如 "第一章 "、"第1章 "
|
|
837
|
+
text = re.sub(r'^第[一二三四五六七八九十\d]+章\s*', '', text)
|
|
838
|
+
return text.strip()
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def generate_docx(nodes, output_path, title_text):
|
|
842
|
+
"""根据节点列表生成 docx 文件"""
|
|
843
|
+
doc = Document()
|
|
844
|
+
|
|
845
|
+
# --- 默认样式 ---
|
|
846
|
+
style = doc.styles['Normal']
|
|
847
|
+
style.font.size = Pt(10.5)
|
|
848
|
+
style.font.name = 'Times New Roman'
|
|
849
|
+
style.element.rPr.rFonts.set(qn('w:eastAsia'), '宋体')
|
|
850
|
+
style.paragraph_format.line_spacing = 1.3
|
|
851
|
+
style.paragraph_format.space_before = Pt(0)
|
|
852
|
+
style.paragraph_format.space_after = Pt(0)
|
|
853
|
+
|
|
854
|
+
# --- 节/页边距设置 ---
|
|
855
|
+
section = doc.sections[0]
|
|
856
|
+
# 页边距:左3cm 右2cm 上2cm 下2cm
|
|
857
|
+
section.left_margin = Cm(3)
|
|
858
|
+
section.right_margin = Cm(2)
|
|
859
|
+
section.top_margin = Cm(2)
|
|
860
|
+
section.bottom_margin = Cm(2)
|
|
861
|
+
header = section.header
|
|
862
|
+
header.is_linked_to_previous = False
|
|
863
|
+
# 左顶格 xxxxx
|
|
864
|
+
hp_left = header.paragraphs[0]
|
|
865
|
+
hp_left.paragraph_format.space_after = Pt(0)
|
|
866
|
+
run_l = hp_left.add_run('xxxxx')
|
|
867
|
+
set_run_font(run_l, '黑体', size_pt=9)
|
|
868
|
+
# 右顶格 题目
|
|
869
|
+
hp_right = header.add_paragraph()
|
|
870
|
+
hp_right.alignment = WD_ALIGN_PARAGRAPH.RIGHT
|
|
871
|
+
hp_right.paragraph_format.space_before = Pt(0)
|
|
872
|
+
run_r = hp_right.add_run(title_text)
|
|
873
|
+
set_run_font(run_r, '黑体', size_pt=9)
|
|
874
|
+
|
|
875
|
+
# --- 页脚/页码设置 ---
|
|
876
|
+
section.footer_distance = Cm(1)
|
|
877
|
+
footer = section.footer
|
|
878
|
+
footer.is_linked_to_previous = False
|
|
879
|
+
fp = footer.paragraphs[0]
|
|
880
|
+
fp.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
881
|
+
# "第"
|
|
882
|
+
r1 = fp.add_run('第')
|
|
883
|
+
set_run_font(r1, '宋体', size_pt=10.5)
|
|
884
|
+
# PAGE 字段
|
|
885
|
+
r_page = fp.add_run()
|
|
886
|
+
fc1 = OxmlElement('w:fldChar'); fc1.set(qn('w:fldCharType'), 'begin')
|
|
887
|
+
r_page._r.append(fc1)
|
|
888
|
+
it1 = OxmlElement('w:instrText'); it1.set(qn('xml:space'), 'preserve'); it1.text = ' PAGE '
|
|
889
|
+
r_page._r.append(it1)
|
|
890
|
+
fc2 = OxmlElement('w:fldChar'); fc2.set(qn('w:fldCharType'), 'end')
|
|
891
|
+
r_page._r.append(fc2)
|
|
892
|
+
# "页 共"
|
|
893
|
+
r2 = fp.add_run('页 共')
|
|
894
|
+
set_run_font(r2, '宋体', size_pt=10.5)
|
|
895
|
+
# NUMPAGES 字段
|
|
896
|
+
r_total = fp.add_run()
|
|
897
|
+
fc3 = OxmlElement('w:fldChar'); fc3.set(qn('w:fldCharType'), 'begin')
|
|
898
|
+
r_total._r.append(fc3)
|
|
899
|
+
it2 = OxmlElement('w:instrText'); it2.set(qn('xml:space'), 'preserve'); it2.text = ' NUMPAGES '
|
|
900
|
+
r_total._r.append(it2)
|
|
901
|
+
fc4 = OxmlElement('w:fldChar'); fc4.set(qn('w:fldCharType'), 'end')
|
|
902
|
+
r_total._r.append(fc4)
|
|
903
|
+
# "页"
|
|
904
|
+
r3 = fp.add_run('页')
|
|
905
|
+
set_run_font(r3, '宋体', size_pt=10.5)
|
|
906
|
+
|
|
907
|
+
# --- 计数器 ---
|
|
908
|
+
chapter_path = [1] # 一级标题序号
|
|
909
|
+
fig_counter = {} # key: chapter index tuple, value: counter
|
|
910
|
+
tab_counter = {}
|
|
911
|
+
eq_counter = {}
|
|
912
|
+
|
|
913
|
+
def get_chapter_key():
|
|
914
|
+
return tuple(chapter_path[:1]) # 只用一级序号作为章key
|
|
915
|
+
|
|
916
|
+
def incr_fig():
|
|
917
|
+
key = get_chapter_key()
|
|
918
|
+
fig_counter[key] = fig_counter.get(key, 0) + 1
|
|
919
|
+
return fig_counter[key]
|
|
920
|
+
|
|
921
|
+
def incr_tab():
|
|
922
|
+
key = get_chapter_key()
|
|
923
|
+
tab_counter[key] = tab_counter.get(key, 0) + 1
|
|
924
|
+
return tab_counter[key]
|
|
925
|
+
|
|
926
|
+
def incr_eq():
|
|
927
|
+
key = get_chapter_key()
|
|
928
|
+
eq_counter[key] = eq_counter.get(key, 0) + 1
|
|
929
|
+
return eq_counter[key]
|
|
930
|
+
|
|
931
|
+
def make_fig_label():
|
|
932
|
+
return f"图{chapter_path[0]}-{incr_fig()}"
|
|
933
|
+
|
|
934
|
+
def make_tab_label():
|
|
935
|
+
return f"表{chapter_path[0]}-{incr_tab()}"
|
|
936
|
+
|
|
937
|
+
# --- 遍历节点生成内容 ---
|
|
938
|
+
for node in nodes:
|
|
939
|
+
t = node['type']
|
|
940
|
+
|
|
941
|
+
if t == 'title':
|
|
942
|
+
add_empty_para(doc)
|
|
943
|
+
p = doc.add_paragraph()
|
|
944
|
+
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
945
|
+
run = p.add_run(node['text'])
|
|
946
|
+
set_run_font(run, '黑体', size_pt=16)
|
|
947
|
+
add_empty_para(doc)
|
|
948
|
+
|
|
949
|
+
elif t == 'heading':
|
|
950
|
+
lv = node['level']
|
|
951
|
+
if lv == 1:
|
|
952
|
+
# 一级标题:新页 + 三号黑体居中
|
|
953
|
+
chapter_path[0] += 1
|
|
954
|
+
chapter_path[1:] = [0] # reset sub-levels
|
|
955
|
+
|
|
956
|
+
add_empty_para(doc)
|
|
957
|
+
p = doc.add_paragraph()
|
|
958
|
+
add_page_break_before(p)
|
|
959
|
+
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
960
|
+
set_outline_level(p, 1)
|
|
961
|
+
run = p.add_run(node['text'])
|
|
962
|
+
set_run_font(run, '黑体', size_pt=16)
|
|
963
|
+
add_empty_para(doc)
|
|
964
|
+
|
|
965
|
+
elif lv == 2:
|
|
966
|
+
# 二级标题:四号黑体顶格不加粗,上下不空行
|
|
967
|
+
p = doc.add_paragraph()
|
|
968
|
+
set_outline_level(p, 2)
|
|
969
|
+
p.paragraph_format.first_line_indent = Pt(0)
|
|
970
|
+
run = p.add_run(node['text'])
|
|
971
|
+
set_run_font(run, '黑体', size_pt=14, bold=False)
|
|
972
|
+
|
|
973
|
+
elif lv >= 3:
|
|
974
|
+
# 三级标题:小四宋体不加粗,首行缩进同正文,上下不空行
|
|
975
|
+
p = doc.add_paragraph()
|
|
976
|
+
set_outline_level(p, min(lv, 3))
|
|
977
|
+
p.paragraph_format.first_line_indent = Pt(21)
|
|
978
|
+
run = p.add_run(node['text'])
|
|
979
|
+
set_run_font(run, '宋体', size_pt=12, bold=False)
|
|
980
|
+
|
|
981
|
+
elif t == 'para':
|
|
982
|
+
p = doc.add_paragraph()
|
|
983
|
+
p.paragraph_format.first_line_indent = Pt(21)
|
|
984
|
+
p.paragraph_format.line_spacing = 1.3
|
|
985
|
+
p.paragraph_format.space_before = Pt(0)
|
|
986
|
+
p.paragraph_format.space_after = Pt(0)
|
|
987
|
+
segments = node.get('segments', [{'type': 'text', 'content': node['text']}])
|
|
988
|
+
for seg in segments:
|
|
989
|
+
if seg['type'] == 'text':
|
|
990
|
+
if seg['content']: # 跳过空字符串
|
|
991
|
+
run = p.add_run(seg['content'])
|
|
992
|
+
set_run_font(run, '宋体', size_pt=10.5)
|
|
993
|
+
else: # math — 插入 OMML 公式
|
|
994
|
+
try:
|
|
995
|
+
omml = latex_to_omml(seg['content'], display=False)
|
|
996
|
+
p._element.append(omml)
|
|
997
|
+
except Exception:
|
|
998
|
+
# 解析失败时回退到斜体文本
|
|
999
|
+
run = p.add_run(seg['content'])
|
|
1000
|
+
set_run_font(run, '宋体', en_font='Times New Roman', size_pt=10.5)
|
|
1001
|
+
run.font.italic = True
|
|
1002
|
+
|
|
1003
|
+
elif t == 'image':
|
|
1004
|
+
url = node['url']
|
|
1005
|
+
alt = node['alt']
|
|
1006
|
+
img_path = download_image(url)
|
|
1007
|
+
if img_path is None:
|
|
1008
|
+
img_path = add_placeholder_image(doc, alt or url)
|
|
1009
|
+
|
|
1010
|
+
w, h = calc_image_size(img_path)
|
|
1011
|
+
|
|
1012
|
+
add_empty_para(doc)
|
|
1013
|
+
# 图片居中
|
|
1014
|
+
p_img = doc.add_paragraph()
|
|
1015
|
+
p_img.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
1016
|
+
run_img = p_img.add_run()
|
|
1017
|
+
run_img.add_picture(img_path, width=w)
|
|
1018
|
+
|
|
1019
|
+
# 图题:小五宋体加粗居中
|
|
1020
|
+
p_cap = doc.add_paragraph()
|
|
1021
|
+
p_cap.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
1022
|
+
run_cap = p_cap.add_run(f'{make_fig_label()} {alt}')
|
|
1023
|
+
set_run_font(run_cap, '宋体', size_pt=9, bold=True)
|
|
1024
|
+
add_empty_para(doc)
|
|
1025
|
+
|
|
1026
|
+
# 清理临时图片
|
|
1027
|
+
try:
|
|
1028
|
+
os.unlink(img_path)
|
|
1029
|
+
except Exception:
|
|
1030
|
+
pass
|
|
1031
|
+
|
|
1032
|
+
elif t == 'table':
|
|
1033
|
+
header_cells = node['header']
|
|
1034
|
+
rows = node['rows']
|
|
1035
|
+
ncols = len(header_cells)
|
|
1036
|
+
|
|
1037
|
+
# 检查前一个节点是否为表题段落
|
|
1038
|
+
# (在 parse 阶段已处理,这里简化:表题由前一个 para 节点承担,此处检测)
|
|
1039
|
+
# 实际简化处理:如果有 header text,自动加表题
|
|
1040
|
+
|
|
1041
|
+
# 表题
|
|
1042
|
+
add_empty_para(doc)
|
|
1043
|
+
p_tab_cap = doc.add_paragraph()
|
|
1044
|
+
p_tab_cap.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
1045
|
+
run_tc = p_tab_cap.add_run(f'{make_tab_label()} {header_cells[0] if header_cells else ""}')
|
|
1046
|
+
set_run_font(run_tc, '宋体', size_pt=10.5, bold=True)
|
|
1047
|
+
|
|
1048
|
+
# 表格
|
|
1049
|
+
table = doc.add_table(rows=len(rows) + 1, cols=ncols)
|
|
1050
|
+
table.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
1051
|
+
set_three_line_table(table)
|
|
1052
|
+
|
|
1053
|
+
# 表头行设为重复标题行(跨页自动出现)
|
|
1054
|
+
tblHeader = OxmlElement('w:tblHeader')
|
|
1055
|
+
trPr = table.rows[0]._tr.get_or_add_trPr()
|
|
1056
|
+
trPr.append(tblHeader)
|
|
1057
|
+
|
|
1058
|
+
# 表头行
|
|
1059
|
+
for j, cell_text in enumerate(header_cells):
|
|
1060
|
+
cell = table.rows[0].cells[j]
|
|
1061
|
+
cell.paragraphs[0].clear()
|
|
1062
|
+
cell.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
1063
|
+
run = cell.paragraphs[0].add_run(cell_text)
|
|
1064
|
+
set_run_font(run, '宋体', size_pt=9)
|
|
1065
|
+
cell.paragraphs[0].paragraph_format.first_line_indent = Pt(0)
|
|
1066
|
+
|
|
1067
|
+
# 数据行
|
|
1068
|
+
for i, row_data in enumerate(rows):
|
|
1069
|
+
for j, cell_text in enumerate(row_data):
|
|
1070
|
+
cell = table.rows[i + 1].cells[j]
|
|
1071
|
+
cell.paragraphs[0].clear()
|
|
1072
|
+
cell.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.LEFT
|
|
1073
|
+
run = cell.paragraphs[0].add_run(cell_text)
|
|
1074
|
+
set_run_font(run, '宋体', size_pt=9)
|
|
1075
|
+
cell.paragraphs[0].paragraph_format.first_line_indent = Pt(0)
|
|
1076
|
+
|
|
1077
|
+
add_empty_para(doc)
|
|
1078
|
+
|
|
1079
|
+
elif t == 'code':
|
|
1080
|
+
p = doc.add_paragraph()
|
|
1081
|
+
p.paragraph_format.left_indent = Cm(1)
|
|
1082
|
+
run = p.add_run(node['text'])
|
|
1083
|
+
set_run_font(run, '宋体', en_font='Courier New', size_pt=10.5)
|
|
1084
|
+
|
|
1085
|
+
elif t == 'display_math':
|
|
1086
|
+
# 行间公式:上下各空一行,公式居中,编号右对齐
|
|
1087
|
+
add_empty_para(doc)
|
|
1088
|
+
p = doc.add_paragraph()
|
|
1089
|
+
# 设置段落 tab stops:居中 + 右对齐
|
|
1090
|
+
pPr = p._element.get_or_add_pPr()
|
|
1091
|
+
tabs = OxmlElement('w:tabs')
|
|
1092
|
+
# 居中 tab:左边距3cm右边距2cm,可用宽度16cm,中心8cm → 4536 twips
|
|
1093
|
+
center_tab = OxmlElement('w:tab')
|
|
1094
|
+
center_tab.set(qn('w:val'), 'center')
|
|
1095
|
+
center_tab.set(qn('w:pos'), '4536')
|
|
1096
|
+
tabs.append(center_tab)
|
|
1097
|
+
# 右对齐 tab:16cm → 9072 twips
|
|
1098
|
+
right_tab = OxmlElement('w:tab')
|
|
1099
|
+
right_tab.set(qn('w:val'), 'right')
|
|
1100
|
+
right_tab.set(qn('w:pos'), '9072')
|
|
1101
|
+
tabs.append(right_tab)
|
|
1102
|
+
pPr.append(tabs)
|
|
1103
|
+
# tab → 居中位置
|
|
1104
|
+
run_t1 = p.add_run()
|
|
1105
|
+
tab1 = OxmlElement('w:tab')
|
|
1106
|
+
run_t1._r.append(tab1)
|
|
1107
|
+
# 插入 OMML 行内公式(m:oMath 在 w:r 同级参与排版)
|
|
1108
|
+
try:
|
|
1109
|
+
omml = latex_to_omml(node['text'], display=False)
|
|
1110
|
+
p._element.append(omml)
|
|
1111
|
+
except Exception:
|
|
1112
|
+
run_fb = p.add_run(node['text'])
|
|
1113
|
+
set_run_font(run_fb, '宋体', en_font='Times New Roman', size_pt=10.5)
|
|
1114
|
+
run_fb.font.italic = True
|
|
1115
|
+
# tab → 右对齐位置 → 公式编号
|
|
1116
|
+
run_t2 = p.add_run()
|
|
1117
|
+
tab2 = OxmlElement('w:tab')
|
|
1118
|
+
run_t2._r.append(tab2)
|
|
1119
|
+
ch_num = chapter_path[0]
|
|
1120
|
+
eq_num = incr_eq()
|
|
1121
|
+
# 编号格式:括号用宋体,数字用TNR
|
|
1122
|
+
run_lp = p.add_run('(')
|
|
1123
|
+
set_run_font(run_lp, '宋体', en_font='宋体', size_pt=10.5)
|
|
1124
|
+
run_lnum = p.add_run(f'{ch_num}-{eq_num}')
|
|
1125
|
+
set_run_font(run_lnum, '宋体', en_font='Times New Roman', size_pt=10.5)
|
|
1126
|
+
run_rp = p.add_run(')')
|
|
1127
|
+
set_run_font(run_rp, '宋体', en_font='宋体', size_pt=10.5)
|
|
1128
|
+
add_empty_para(doc)
|
|
1129
|
+
|
|
1130
|
+
elif t == 'list':
|
|
1131
|
+
for idx, item in enumerate(node['children'], 1):
|
|
1132
|
+
p = doc.add_paragraph()
|
|
1133
|
+
p.paragraph_format.first_line_indent = Pt(21)
|
|
1134
|
+
p.paragraph_format.line_spacing = 1.3
|
|
1135
|
+
run = p.add_run(f'({idx}){item}')
|
|
1136
|
+
set_run_font(run, '宋体', size_pt=10.5)
|
|
1137
|
+
|
|
1138
|
+
elif t == 'empty':
|
|
1139
|
+
pass # 段落间不空行,空行由标题/图表handler显式添加
|
|
1140
|
+
|
|
1141
|
+
doc.save(output_path)
|
|
1142
|
+
return output_path
|
|
1143
|
+
|
|
1144
|
+
|
|
1145
|
+
# ============================================================
|
|
1146
|
+
# 主入口
|
|
1147
|
+
# ============================================================
|
|
1148
|
+
|
|
1149
|
+
def main():
|
|
1150
|
+
parser = argparse.ArgumentParser(
|
|
1151
|
+
description='mddoc - Markdown 转学术格式 DOCX',
|
|
1152
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1153
|
+
epilog="""
|
|
1154
|
+
示例:
|
|
1155
|
+
python md2docx.py paper.md # 转换文件,输出到同目录
|
|
1156
|
+
python md2docx.py paper.md -o output.docx # 指定输出路径
|
|
1157
|
+
python md2docx.py --text "# 标题\\n\\n正文内容" -o out.docx # 直接转换文本
|
|
1158
|
+
""".strip())
|
|
1159
|
+
parser.add_argument('input', nargs='?', help='输入 Markdown 文件路径')
|
|
1160
|
+
parser.add_argument('-o', '--output', help='输出 DOCX 文件路径')
|
|
1161
|
+
parser.add_argument('--text', help='直接传入 Markdown 文本(不需要文件)')
|
|
1162
|
+
args = parser.parse_args()
|
|
1163
|
+
|
|
1164
|
+
if not args.input and not args.text:
|
|
1165
|
+
parser.print_help()
|
|
1166
|
+
sys.exit(1)
|
|
1167
|
+
|
|
1168
|
+
# 读取内容
|
|
1169
|
+
if args.text:
|
|
1170
|
+
md_content = args.text
|
|
1171
|
+
source_dir = os.getcwd()
|
|
1172
|
+
else:
|
|
1173
|
+
with open(args.input, 'r', encoding='utf-8') as f:
|
|
1174
|
+
md_content = f.read()
|
|
1175
|
+
source_dir = os.path.dirname(os.path.abspath(args.input))
|
|
1176
|
+
|
|
1177
|
+
# 解析
|
|
1178
|
+
nodes = parse_markdown(md_content)
|
|
1179
|
+
|
|
1180
|
+
# 提取题目
|
|
1181
|
+
title_node = next((n for n in nodes if n['type'] == 'title'), None)
|
|
1182
|
+
if title_node is None:
|
|
1183
|
+
print("错误:未找到文档标题(第一个 # 标题)", file=sys.stderr)
|
|
1184
|
+
sys.exit(1)
|
|
1185
|
+
title_text = extract_title_text(title_node)
|
|
1186
|
+
|
|
1187
|
+
# 确定输出路径
|
|
1188
|
+
if args.output:
|
|
1189
|
+
output_path = args.output
|
|
1190
|
+
else:
|
|
1191
|
+
safe_name = re.sub(r'[\\/*?:"<>|]', '', title_text)
|
|
1192
|
+
output_path = os.path.join(source_dir, f'{safe_name}.docx')
|
|
1193
|
+
|
|
1194
|
+
# 生成
|
|
1195
|
+
generate_docx(nodes, output_path, title_text)
|
|
1196
|
+
print(f'已生成: {output_path}')
|
|
1197
|
+
|
|
1198
|
+
|
|
1199
|
+
if __name__ == '__main__':
|
|
1200
|
+
main()
|