@cliven/mddocx 1.0.4 → 1.0.5

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mddocx",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
4
4
  "description": "Markdown 转学术格式 Word 文档 (DOCX)。支持三线表、图题/表题自动编号、页码、页眉等学术论文排版规范。",
5
5
  "author": { "name": "Quan Guanyu" },
6
6
  "keywords": ["markdown", "docx", "word", "academic", "论文", "学术", "转换"],
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mddocx",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
4
4
  "description": "Markdown 转学术格式 Word 文档 (DOCX)。支持三线表、图题/表题自动编号、页码、页眉等学术论文排版规范。",
5
5
  "author": "Quan Guanyu",
6
6
  "homepage": "https://github.com/Trisia/mddocx",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mddocx",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
4
4
  "description": "Markdown 转学术格式 Word 文档 (DOCX)。支持三线表、图题/表题自动编号、页码、页眉等学术论文排版规范。",
5
5
  "author": "Quan Guanyu",
6
6
  "homepage": "https://github.com/Trisia/mddocx",
package/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
  [![CI](https://github.com/Trisia/mddocx/actions/workflows/release.yml/badge.svg)](https://github.com/Trisia/mddocx/actions/workflows/release.yml)
12
12
  [![Clawhub](https://img.shields.io/badge/Clawhub-mddocx-8b5cf6)](https://clawhub.ai/Trisia/mddoc)
13
13
 
14
- 将 Markdown 转换为符合学术规范的 Word 文档的Agent Skill,支持 LaTeX 公式(OMML)、三线表、图题/表题自动编号、页码、页眉等学术论文排版规范。
14
+ 将 Markdown 转换为符合学术规范的 Word 文档的Agent Skill,支持 LaTeX 公式(OMML,含矩阵/分段函数等环境)、三线表、图题/表题自动编号、页码、页眉等学术论文排版规范。
15
15
 
16
16
  ![MD与DOCX生成效果](./examples/demo.png)
17
17
 
@@ -80,7 +80,41 @@ python skills/mddoc/scripts/md2docx.py --text "# 标题\n\n正文" -o out.docx
80
80
  | 表题 | 五号(10.5pt)宋体加粗居中、"表1-1 xxx"格式 |
81
81
  | 页码 | "第×页 共×页"、页脚边距1cm |
82
82
  | 列表 | 有序列表用(1)(2)(3)序号 |
83
- | 行内公式 | $...$ 转 OMML、嵌于段落、WPS/Word 可渲染 |
83
+ | 行内公式 | $...$ 转 OMML、嵌于段落 |
84
84
  | 行间公式 | $$...$$ 转 OMML 居中、编号(章-序号)右对齐 |
85
+ | LaTeX 环境 | matrix/bmatrix/pmatrix/vmatrix 矩阵、cases 分段函数 |
86
+ | 代码块 | Courier New 等宽、五号、左缩进 |
85
87
  | 页边距 | 左3cm 右2cm 上2cm 下2cm |
86
88
 
89
+ ## 升级
90
+
91
+ ### npm
92
+
93
+ ```bash
94
+ npm update -g @cliven/mddocx # 全局安装升级
95
+ npx @cliven/mddocx@latest paper.md # npx 始终使用最新版
96
+ ```
97
+
98
+ ### Claude Code / Codex / Cursor
99
+
100
+ ```bash
101
+ # 插件方式安装的,进入插件目录拉取
102
+ cd ~/.claude/plugins/mddocx && git pull # Claude Code
103
+ cd ~/.codex/plugins/mddocx && git pull # Codex
104
+ cd ~/.cursor/plugins/mddocx && git pull # Cursor
105
+
106
+ # 或重新克隆安装
107
+ git clone https://github.com/Trisia/mddocx /tmp/mddocx
108
+ cp -rf /tmp/mddocx/skills/mddoc ~/.claude/skills/mddoc
109
+ ```
110
+
111
+ ### OpenCode
112
+
113
+ 更新 `opencode.json` 中的插件引用,重启即可自动拉取最新版本。
114
+
115
+ ### Python 依赖
116
+
117
+ ```bash
118
+ pip install --upgrade python-docx Pillow requests mistune
119
+ ```
120
+
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@cliven/mddocx",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
4
4
  "description": "Markdown 转学术格式 DOCX",
5
5
  "type": "module",
6
6
  "main": ".opencode/plugins/mddocx.js",
7
7
  "bin": {
8
- "mddocx": "./bin/mddocx.js"
8
+ "mddocx": "bin/mddocx.js"
9
9
  },
10
10
  "files": [
11
11
  "skills/",
@@ -16,10 +16,22 @@
16
16
  ".cursor-plugin/",
17
17
  "hooks/"
18
18
  ],
19
- "keywords": ["markdown", "docx", "academic", "claude-code", "codex", "opencode", "skill"],
19
+ "keywords": [
20
+ "markdown",
21
+ "docx",
22
+ "academic",
23
+ "claude-code",
24
+ "codex",
25
+ "opencode",
26
+ "skill"
27
+ ],
20
28
  "repository": {
21
29
  "type": "git",
22
- "url": "https://github.com/Trisia/mddocx"
30
+ "url": "git+https://github.com/Trisia/mddocx.git"
23
31
  },
24
- "license": "MIT"
32
+ "license": "MIT",
33
+ "publishConfig": {
34
+ "access": "public",
35
+ "registry": "https://registry.npmjs.org"
36
+ }
25
37
  }
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  name: mddoc
3
+ version: 1.0.5
3
4
  description: 将 Markdown 内容转换为特定学术格式的 Word 文档 (.docx)。当用户想要将 Markdown 文件、粘贴的 Markdown 文本转换为格式化的 docx 文档时使用,特别是学术论文、技术报告、毕业论文等需要严格格式要求的场景。触发词包括:/mddoc、markdown转docx、md转word、生成格式化文档、学术格式转换。即使用户只说"把这个转成word"而内容是Markdown,也应使用此技能。
4
5
  ---
5
6
 
@@ -8,9 +9,14 @@ description: 将 Markdown 内容转换为特定学术格式的 Word 文档 (.doc
8
9
  ## 快速开始
9
10
 
10
11
  ```bash
11
- # 安装依赖(仅首次)
12
+ # 首次使用:创建虚拟环境并安装依赖
13
+ python3 -m venv .venv
14
+ source .venv/bin/activate
12
15
  pip install python-docx Pillow requests mistune
13
16
 
17
+ # 后续使用:直接激活虚拟环境
18
+ source .venv/bin/activate
19
+
14
20
  # 转换 Markdown 文件 → 输出到同目录
15
21
  python <skill-path>/scripts/md2docx.py paper.md
16
22
 
@@ -26,7 +32,10 @@ python <skill-path>/scripts/md2docx.py --text "# 标题\n\n正文内容" -o out.
26
32
  ## 工作流程
27
33
 
28
34
  1. **读取输入** — 若用户粘贴 Markdown 文本则直接读取;若用户提供文件路径(含 `@` 引用)则读取该文件
29
- 2. **安装依赖**(如未安装)— `pip install python-docx Pillow requests mistune`
35
+ 2. **检查并准备环境** 按以下顺序检查,命中即跳过后续步骤:
36
+ - 若 `.venv/bin/python` 存在且能 `import docx, PIL, requests, mistune` → 直接使用
37
+ - 若 `.venv` 存在但缺依赖 → `source .venv/bin/activate && pip install python-docx Pillow requests mistune`
38
+ - 若 `.venv` 不存在 → `python3 -m venv .venv && source .venv/bin/activate && pip install python-docx Pillow requests mistune`
30
39
  3. **执行转换** — 优先使用内置脚本 `scripts/md2docx.py`;若 Markdown 结构特殊则参照下方格式规范编写自定义脚本
31
40
  4. **确定输出** — 文件路径输入→同目录;粘贴内容→当前目录;文件名=「题目.docx」(题目从第一个 `# 标题` 提取;若无 `#` 标题则使用输入文件名,`--text` 模式为「未命名文档.docx」)
32
41
  5. **验证** — 检查 outline level、图片嵌入、页眉、分页符
@@ -0,0 +1,261 @@
1
+ # LaTeX 数学公式覆盖测试(IHKYoung 手册 + 完整补全)
2
+
3
+ 本文件测试所有已实现的 LaTeX 数学公式语法。
4
+
5
+ ---
6
+
7
+ ## 矩阵环境
8
+
9
+ ### bmatrix(方括号)
10
+
11
+ $$
12
+ \begin{bmatrix} a & b \\ c & d \end{bmatrix}
13
+ $$
14
+
15
+ ### pmatrix(圆括号)
16
+
17
+ $$
18
+ \begin{pmatrix} x_{11} & x_{12} \\ x_{21} & x_{22} \end{pmatrix}
19
+ $$
20
+
21
+ ### vmatrix(单竖线行列式)
22
+
23
+ $$
24
+ \begin{vmatrix} a & b \\ c & d \end{vmatrix}
25
+ $$
26
+
27
+ ### Vmatrix(双竖线范数)
28
+
29
+ $$
30
+ \begin{Vmatrix} a & b \\ c & d \end{Vmatrix}
31
+ $$
32
+
33
+ ### matrix(无括号)
34
+
35
+ $$
36
+ \begin{matrix} 1 & 0 \\ 0 & 1 \end{matrix}
37
+ $$
38
+
39
+ ---
40
+
41
+ ## cases 环境(分段函数)
42
+
43
+ $$
44
+ f(x) = \begin{cases} 0 & x < 0 \\ x^2 & 0 \leq x < 1 \\ 1 & x \geq 1 \end{cases}
45
+ $$
46
+
47
+ ---
48
+
49
+ ## align 环境(多行对齐)
50
+
51
+ $$
52
+ \begin{align} x + y &= 2 \\ x - y &= 0 \end{align}
53
+ $$
54
+
55
+ ---
56
+
57
+ ## 字体命令
58
+
59
+ ### 粗体
60
+
61
+ 行内: $\mathbf{A} = \mathbf{B} + \mathbf{C}$
62
+
63
+ ### 斜体
64
+
65
+ 行内: $\mathit{variable}$
66
+
67
+ ### 手写体(花体)
68
+
69
+ 行内: $\mathcal{L}$ $\mathcal{A}$
70
+
71
+ ### 黑板粗体
72
+
73
+ 行内: $\mathbb{R}$ $\mathbb{N}$ $\mathbb{Z}$ $\mathbb{C}$
74
+
75
+ ### 正体(罗马体)
76
+
77
+ 行内: $\mathrm{Var}(X)$
78
+
79
+ ### 等宽体
80
+
81
+ 行内: $\mathtt{code}$
82
+
83
+ ### 粗体符号
84
+
85
+ $\boldsymbol{\alpha} + \bm{\beta} = \mathbb{E}[X]$
86
+
87
+ ---
88
+
89
+ ## 希腊字母
90
+
91
+ ### 小写
92
+
93
+ $\alpha$ $\beta$ $\gamma$ $\delta$ $\epsilon$ $\varepsilon$ $\zeta$ $\eta$ $\theta$ $\vartheta$
94
+
95
+ $\iota$ $\kappa$ $\lambda$ $\mu$ $\nu$ $\xi$ $\pi$ $\rho$ $\sigma$ $\tau$ $\upsilon$ $\phi$ $\varphi$ $\chi$ $\psi$ $\omega$
96
+
97
+ ### 大写
98
+
99
+ $\Gamma$ $\Delta$ $\Theta$ $\Lambda$ $\Xi$ $\Pi$ $\Sigma$ $\Upsilon$ $\Phi$ $\Psi$ $\Omega$
100
+
101
+ ### 变体
102
+
103
+ $\varpi$ $\varrho$ $\varsigma$
104
+
105
+ ---
106
+
107
+ ## 二元运算符
108
+
109
+ $+$ $-$ $\times$ $\div$ $\cdot$ $\oplus$ $\ominus$ $\otimes$ $\oslash$ $\odot$ $\star$ $\circ$ $\bullet$ $\pm$ $\mp$
110
+
111
+ ---
112
+
113
+ ## 关系符号
114
+
115
+ $=$ $\neq$ $\approx$ $\equiv$ $<$ $>$ $\leq$ $\geq$ $\ge$ $\le$ $\ne$
116
+
117
+ $\ll$ $\gg$ $\prec$ $\succ$ $\preceq$ $\succeq$ $\sim$ $\nsim$ $\simeq$ $\asymp$ $\propto$
118
+
119
+ ---
120
+
121
+ ## 逻辑符号
122
+
123
+ $\wedge$ $\vee$ $\neg$ $\Rightarrow$ $\Leftrightarrow$ $\forall$ $\exists$ $\nexists$ $\top$ $\bot$
124
+
125
+ ---
126
+
127
+ ## 集合符号
128
+
129
+ $\emptyset$ $\in$ $\notin$ $\subseteq$ $\subset$ $\nsubseteq$ $\supset$ $\supseteq$ $\nsupseteq$ $\cup$ $\cap$ $\setminus$
130
+
131
+ ---
132
+
133
+ ## 箭头
134
+
135
+ $\to$ $\gets$ $\leftarrow$ $\rightarrow$ $\Rightarrow$ $\Leftarrow$ $\leftrightarrow$ $\Leftrightarrow$
136
+
137
+ $\longrightarrow$ $\longleftarrow$ $\mapsto$ $\longmapsto$ $\uparrow$ $\downarrow$ $\updownarrow$
138
+
139
+ $\Uparrow$ $\Downarrow$ $\Updownarrow$
140
+
141
+ ---
142
+
143
+ ## 分数与根式
144
+
145
+ ### 分数
146
+
147
+ $\frac{a}{b}$ $\dfrac{1}{2}$ $\tfrac{1}{3}$
148
+
149
+ $$
150
+ \frac{d}{dx} f(x) \qquad \frac{\partial f}{\partial x}
151
+ $$
152
+
153
+ ### 根式
154
+
155
+ $\sqrt{x}$ $\sqrt[n]{x}$
156
+
157
+ ---
158
+
159
+ ## 指数与对数
160
+
161
+ $a^b$ $e^x$ $\log x$ $\ln x$ $\log_a b$ $\exp(x)$
162
+
163
+ ---
164
+
165
+ ## 极限
166
+
167
+ $\lim_{x \to a} f(x)$ $\lim_{x \to \infty} f(x)$
168
+
169
+ ---
170
+
171
+ ## 积分与求和
172
+
173
+ $\int f(x)dx$ $\int_{a}^{b} f(x)dx$ $\iint$ $\iiint$
174
+
175
+ $\sum_{i=1}^{n} x_i$ $\prod_{i=1}^{n} x_i$
176
+
177
+ ---
178
+
179
+ ## 重音符号
180
+
181
+ $\hat{x}$ $\bar{x}$ $\vec{v}$ $\dot{x}$ $\ddot{x}$ $\tilde{x}$
182
+
183
+ $$
184
+ \overrightarrow{AB} = \overleftarrow{BA}
185
+ $$
186
+
187
+ ---
188
+
189
+ ## 定界符
190
+
191
+ $$
192
+ \left( \frac{a + b}{c} \right) \qquad \left[ \frac{x}{y} \right]
193
+ $$
194
+
195
+ ---
196
+
197
+ ## 函数名
198
+
199
+ $\sin$ $\cos$ $\tan$ $\cot$ $\sec$ $\csc$
200
+
201
+ $\arcsin$ $\arccos$ $\arctan$
202
+
203
+ $\sinh$ $\cosh$ $\tanh$ $\coth$
204
+
205
+ $\log$ $\lg$ $\ln$ $\exp$
206
+
207
+ $\lim$ $\max$ $\min$ $\sup$ $\inf$
208
+
209
+ $\det$ $\dim$ $\ker$ $\gcd$
210
+
211
+ ---
212
+
213
+ ## 文本
214
+
215
+ $\text{当且仅当}$ $x \text{ is even}$
216
+
217
+ ---
218
+
219
+ ## 间距
220
+
221
+ $a\,b$ $a\quad b$ $a\qquad b$
222
+
223
+ ---
224
+
225
+ ## 颜色
226
+
227
+ $\textcolor{red}{红色文字}$ $\textcolor{blue}{蓝色文字}$
228
+
229
+ ---
230
+
231
+ ## 转义字符
232
+
233
+ $\#$ $\$ $ \% $ \& $ \_ $ \{ $ \} $ \backslash $
234
+
235
+ ---
236
+
237
+ ## 综合复杂公式
238
+
239
+ $$
240
+ \frac{1}{\sqrt{2\pi\sigma^2}} \exp\left(-\frac{(x-\mu)^2}{2\sigma^2}\right)
241
+ $$
242
+
243
+ $$
244
+ A\mathbf{v} = \lambda \mathbf{v}
245
+ $$
246
+
247
+ $$
248
+ \mathbb{E}[X] = \sum_{x} x\,P(X=x)
249
+ $$
250
+
251
+ $$
252
+ \mathrm{Var}(X) = \mathbb{E}[(X - \mathbb{E}[X])^2]
253
+ $$
254
+
255
+ $$
256
+ D_{KL}(P \parallel Q) = \sum_x P(x) \log \frac{P(x)}{Q(x)}
257
+ $$
258
+
259
+ $$
260
+ \frac{1}{N} \sum_{i=1}^{N} (y_i - (w^T x_i + b))^2
261
+ $$
@@ -51,6 +51,7 @@ GREEK = {
51
51
  'Xi': 'Ξ', 'Pi': 'Π', 'Sigma': 'Σ', 'Upsilon': 'Υ',
52
52
  'Phi': 'Φ', 'Psi': 'Ψ', 'Omega': 'Ω',
53
53
  'varepsilon': 'ϵ', 'varphi': 'ϕ', 'vartheta': 'ϑ',
54
+ 'varpi': 'ϖ', 'varrho': 'ϱ', 'varsigma': 'ς',
54
55
  }
55
56
 
56
57
  # LaTeX 符号 → Unicode
@@ -72,6 +73,24 @@ SYMBOLS = {
72
73
  'prime': '′', 'emptyset': '∅', 'varnothing': '∅',
73
74
  'langle': '⟨', 'rangle': '⟩', 'lceil': '⌈', 'rceil': '⌉',
74
75
  'lfloor': '⌊', 'rfloor': '⌋',
76
+ # 阶段3 补全:二元运算符
77
+ 'div': '÷', 'ominus': '⊖', 'oslash': '⊘', 'star': '⋆', 'odot': '⊙',
78
+ # 阶段3 补全:关系符
79
+ 'll': '≪', 'gg': '≫', 'prec': '≺', 'succ': '≻',
80
+ 'preceq': '≼', 'succeq': '≽', 'nsim': '≁', 'asymp': '≍',
81
+ # 阶段3 补全:逻辑符号
82
+ 'top': '⊤', 'bot': '⊥', 'neg': '¬', 'nexists': '∄',
83
+ 'wedge': '∧', 'vee': '∨',
84
+ # 阶段3 补全:集合符号
85
+ 'setminus': '∖', 'nsubseteq': '⊈', 'nsupseteq': '⊉',
86
+ # 阶段3 补全:箭头
87
+ 'to': '→', 'longrightarrow': '⟶', 'longmapsto': '⟼',
88
+ 'uparrow': '↑', 'downarrow': '↓', 'updownarrow': '↕',
89
+ 'Uparrow': '⇑', 'Downarrow': '⇓', 'Updownarrow': '⇕',
90
+ 'longleftarrow': '⟵', 'Leftrightarrow': '⇔',
91
+ # 阶段3 补全:常用别名
92
+ 'ge': '≥', 'le': '≤', 'ne': '≠', 'gets': '←',
93
+ 'backslash': '\\',
75
94
  }
76
95
 
77
96
  # 需要特殊处理的函数名
@@ -136,12 +155,198 @@ def _m_wrap_d(children):
136
155
  return d
137
156
 
138
157
 
158
+ def _m_run_with_style(text, italic=True, bold=False, script=None, normal=False):
159
+ """创建带样式属性的 m:r 元素
160
+
161
+ 参数:
162
+ text: 显示文本
163
+ italic: 是否斜体(默认 True)
164
+ bold: 是否粗体
165
+ script: 脚本字体类型 ('script' 对应 \\mathcal, 'double-struck' 对应 \\mathbb)
166
+ normal: 是否正体(覆盖 italic)
167
+
168
+ 返回:
169
+ m:r XML 元素
170
+ """
171
+ r = _m_elem('r')
172
+ rPr = _m_elem('rPr')
173
+
174
+ if normal:
175
+ nor = _m_elem('nor')
176
+ rPr.append(nor)
177
+ elif bold:
178
+ sty = _m_elem('sty')
179
+ sty.set(_m_qn('val'), 'b')
180
+ rPr.append(sty)
181
+ elif script:
182
+ scr = _m_elem('scr')
183
+ scr.set(_m_qn('val'), script)
184
+ rPr.append(scr)
185
+ elif not italic:
186
+ nor = _m_elem('nor')
187
+ rPr.append(nor)
188
+
189
+ # 仅在 rPr 有子元素时追加
190
+ if len(rPr) > 0:
191
+ r.append(rPr)
192
+
193
+ t_elem = _m_elem('t')
194
+ t_elem.set(qn('xml:space'), 'preserve')
195
+ t_elem.text = text
196
+ r.append(t_elem)
197
+ return r
198
+
199
+
200
+ def _build_matrix(cell_ommls, left_delim=None, right_delim=None):
201
+ """从已解析的单元格 OMML 元素构建矩阵结构
202
+
203
+ 参数:
204
+ cell_ommls: 二维列表 [[cell1, cell2, ...], [cell1, cell2, ...], ...]
205
+ 每个 cell 是 OMML 元素列表(由 _LatexParser.parse() 返回)
206
+ left_delim: 左定界符字符(可选,如 '(' '[' '|' '{')
207
+ right_delim: 右定界符字符(可选,如 ')' ']' '|' '}')
208
+
209
+ 返回:
210
+ m:d 或 m:m XML 元素
211
+ """
212
+ ncols = max((len(row) for row in cell_ommls), default=0)
213
+
214
+ mat = _m_elem('m')
215
+ mPr = _m_elem('mPr')
216
+ mcs = _m_elem('mcs')
217
+ mc = _m_elem('mc')
218
+ mcPr = _m_elem('mcPr')
219
+ count = _m_elem('count')
220
+ count.set(_m_qn('val'), str(ncols))
221
+ mcPr.append(count)
222
+ mcJc = _m_elem('mcJc')
223
+ mcJc.set(_m_qn('val'), 'center')
224
+ mcPr.append(mcJc)
225
+ mc.append(mcPr)
226
+ mcs.append(mc)
227
+ mPr.append(mcs)
228
+ mat.append(mPr)
229
+
230
+ # 构建行
231
+ for row in cell_ommls:
232
+ mr = _m_elem('mr')
233
+ for cell_children in row:
234
+ e = _m_elem('e')
235
+ for child in cell_children:
236
+ if isinstance(child, str):
237
+ e.append(_m_run(child))
238
+ elif child is not None:
239
+ e.append(child)
240
+ mr.append(e)
241
+ mat.append(mr)
242
+
243
+ # 有定界符时外层包裹 m:d
244
+ if left_delim is not None or right_delim is not None:
245
+ d = _m_elem('d')
246
+ dPr = _m_elem('dPr')
247
+ if left_delim is not None:
248
+ begChr = _m_elem('begChr')
249
+ begChr.set(_m_qn('val'), left_delim)
250
+ dPr.append(begChr)
251
+ if right_delim is not None:
252
+ endChr = _m_elem('endChr')
253
+ endChr.set(_m_qn('val'), right_delim)
254
+ dPr.append(endChr)
255
+ # 只有左定界符时也需显式声明右定界符为空,否则 Word 自动配对
256
+ if left_delim is not None and right_delim is None:
257
+ endChr = _m_elem('endChr')
258
+ endChr.set(_m_qn('val'), '')
259
+ dPr.append(endChr)
260
+ d.append(dPr)
261
+ e = _m_elem('e')
262
+ e.append(mat)
263
+ d.append(e)
264
+ return d
265
+
266
+ return mat
267
+
268
+
269
+ # 矩阵环境 → 定界符映射
270
+ _MATRIX_DELIMS = {
271
+ 'matrix': (None, None),
272
+ 'bmatrix': ('[', ']'),
273
+ 'pmatrix': ('(', ')'),
274
+ 'vmatrix': ('|', '|'),
275
+ 'Vmatrix': ('‖', '‖'),
276
+ }
277
+
278
+
279
+ # 字体样式命令 → _m_run_with_style 参数映射
280
+ _FONT_STYLES = {
281
+ 'mathbf': {'bold': True},
282
+ 'bm': {'bold': True},
283
+ 'boldsymbol': {'bold': True},
284
+ 'mathit': {'italic': True},
285
+ 'mathrm': {'normal': True},
286
+ 'mathcal': {'script': 'script'},
287
+ 'mathscr': {'script': 'script'},
288
+ 'mathbb': {'script': 'double-struck'},
289
+ 'mathtt': {'italic': False}, # 等宽正体
290
+ }
291
+
292
+
293
+ def _apply_style_to_elem(elem, style_args):
294
+ """递归对 OMML 元素树中的所有 m:r 元素应用字体样式
295
+
296
+ 参数:
297
+ elem: OMML XML 元素
298
+ style_args: 传给 _m_run_with_style 的关键字参数字典
299
+
300
+ 返回:
301
+ 应用样式后的元素(可能是新元素)
302
+ """
303
+ tag = elem.tag.split('}')[-1] if '}' in elem.tag else elem.tag
304
+ if tag == 'r':
305
+ # 替换 m:r 元素: 保留文本内容,用 _m_run_with_style 重新生成
306
+ t_elem = elem.find(f'{{{NSM}}}t')
307
+ text = t_elem.text if t_elem is not None else ''
308
+ italic = not style_args.get('normal', False) and style_args.get('italic', True)
309
+ bold = style_args.get('bold', False)
310
+ script = style_args.get('script', None)
311
+ normal = style_args.get('normal', False)
312
+ new_r = _m_run_with_style(text, italic=italic, bold=bold, script=script, normal=normal)
313
+ # 在父元素中替换
314
+ parent = elem.getparent()
315
+ if parent is not None:
316
+ idx = list(parent).index(elem)
317
+ parent[idx] = new_r
318
+ return new_r
319
+ else:
320
+ for child in list(elem):
321
+ _apply_style_to_elem(child, style_args)
322
+ return elem
323
+
324
+
325
+ def _try_merge_text_runs(children):
326
+ """若 children 全为简单 m:r 文本元素,返回合并后的文本;否则返回 None"""
327
+ texts = []
328
+ for child in children:
329
+ if isinstance(child, str):
330
+ texts.append(child)
331
+ continue
332
+ tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
333
+ if tag != 'r':
334
+ return None
335
+ t_elem = child.find(f'{{{NSM}}}t')
336
+ if t_elem is None:
337
+ return None
338
+ texts.append(t_elem.text or '')
339
+ return ''.join(texts)
340
+
341
+
139
342
  class _LatexParser:
140
343
  """LaTeX 数学公式递归下降解析器,生成 OMML XML"""
141
344
 
142
- def __init__(self, latex):
345
+ def __init__(self, latex, in_env=False, display=False):
143
346
  self.s = latex
144
347
  self.pos = 0
348
+ self._in_env = in_env # 是否在矩阵/环境内,影响 & 和 \\ 的处理
349
+ self._display = display # 是否行间公式,影响 \frac 大小
145
350
 
146
351
  def peek(self):
147
352
  if self.pos < len(self.s):
@@ -189,18 +394,177 @@ class _LatexParser:
189
394
  buf.append(c)
190
395
  return ''.join(buf)
191
396
 
397
+ def _scan_until_end(self, env_name):
398
+ """扫描原始文本直到匹配的 \\end{env_name},处理嵌套 \\begin{env_name}。
399
+
400
+ 返回:
401
+ 原始文本内容(不含 \\begin 和 \\end 标记)
402
+ """
403
+ body_chars = []
404
+ depth = 1
405
+ while self.pos < len(self.s) and depth > 0:
406
+ c = self.peek()
407
+ if c == '\\':
408
+ saved = self.pos
409
+ self.consume() # consume '\'
410
+ name = self.read_name()
411
+ if name == 'begin':
412
+ inner_env = self.read_required_arg()
413
+ if inner_env == env_name:
414
+ depth += 1
415
+ body_chars.append(f'\\begin{{{inner_env}}}')
416
+ elif name == 'end':
417
+ inner_env = self.read_required_arg()
418
+ if inner_env == env_name:
419
+ depth -= 1
420
+ if depth == 0:
421
+ break
422
+ body_chars.append(f'\\end{{{inner_env}}}')
423
+ else:
424
+ # 非 begin/end 命令,视为普通内容
425
+ body_chars.append('\\' + name)
426
+ if self.peek() == '{':
427
+ body_chars.append('{' + (self.read_required_arg() or '') + '}')
428
+ else:
429
+ body_chars.append(self.consume())
430
+ return ''.join(body_chars)
431
+
432
+ def _parse_environment(self, env_name):
433
+ """解析 \\begin{env_name}...\\end{env_name} 并生成 OMML 元素
434
+
435
+ 支持的 environment:
436
+ - matrix 系列: matrix, bmatrix, pmatrix, vmatrix, Vmatrix
437
+ - cases: 分段函数
438
+ - align / align* / aligned: 多行对齐
439
+
440
+ 返回:
441
+ OMML XML 元素,或(align 环境)m:oMathPara 元素 / m:oMath 列表
442
+ """
443
+ body = self._scan_until_end(env_name)
444
+
445
+ if env_name == 'cases':
446
+ return self._parse_cases_env(body)
447
+
448
+ if env_name in _MATRIX_DELIMS:
449
+ return self._parse_matrix_env(body, env_name)
450
+
451
+ # align 环境
452
+ if env_name in ('align', 'align*', 'aligned'):
453
+ return self._parse_align_lines(body)
454
+
455
+ # 未知环境:回退为文本
456
+ return _m_run(f'\\begin{{{env_name}}}{body}\\end{{{env_name}}}')
457
+
458
+ def _parse_align_lines(self, body):
459
+ """解析 align 环境体:每行一个公式,& 为对齐点(在 OMML 中移除)
460
+
461
+ 返回:
462
+ m:oMathPara 元素(单行时返回 m:oMath)
463
+ """
464
+ # 按 \\ 分行
465
+ raw_lines = body.split('\\\\')
466
+ omml_lines = []
467
+ for line in raw_lines:
468
+ # 移除 & 对齐标记并清理多余空格
469
+ # 例如: "x + y &= 2" → "x + y = 2"
470
+ text = re.sub(r'\s*&\s*', '', line).strip()
471
+ if text:
472
+ children = _LatexParser(text, in_env=True).parse()
473
+ omml = _m_elem('oMath')
474
+ for c in children:
475
+ omml.append(c if not isinstance(c, str) else _m_run(c))
476
+ omml_lines.append(omml)
477
+
478
+ if not omml_lines:
479
+ return _m_run('')
480
+ if len(omml_lines) == 1:
481
+ return omml_lines[0]
482
+ # 多行:m:oMathPara 包裹
483
+ omp = _m_elem('oMathPara')
484
+ # 设置段落属性:居中对齐
485
+ ompPr = _m_elem('oMathParaPr')
486
+ jc = _m_elem('jc')
487
+ jc.set(_m_qn('val'), 'center')
488
+ ompPr.append(jc)
489
+ omp.append(ompPr)
490
+ for omml in omml_lines:
491
+ omp.append(omml)
492
+ return omp
493
+
494
+ def _parse_matrix_env(self, body, env_name):
495
+ """解析矩阵环境体并生成 OMML 矩阵元素
496
+
497
+ body 按 \\\\ 分行,每行按 & 分列,每个单元格用 _LatexParser 解析。
498
+ """
499
+ # 按 \\ 分行
500
+ row_texts = body.split('\\\\')
501
+ cell_ommls = []
502
+ for row_text in row_texts:
503
+ # 每行按 & 分列
504
+ col_texts = row_text.split('&')
505
+ row_cells = []
506
+ for col_text in col_texts:
507
+ col_text = col_text.strip()
508
+ if col_text:
509
+ cell_children = _LatexParser(col_text, in_env=True).parse()
510
+ else:
511
+ cell_children = []
512
+ row_cells.append(cell_children)
513
+ if row_cells:
514
+ cell_ommls.append(row_cells)
515
+
516
+ if not cell_ommls:
517
+ return _m_run('')
518
+
519
+ left_delim, right_delim = _MATRIX_DELIMS[env_name]
520
+ return _build_matrix(cell_ommls, left_delim, right_delim)
521
+
522
+ def _parse_cases_env(self, body):
523
+ """解析 cases 环境体:左花括号定界符 + 内部矩阵
524
+
525
+ 每行格式: 表达式 & 条件
526
+ """
527
+ row_texts = body.split('\\\\')
528
+ cell_ommls = []
529
+ for row_text in row_texts:
530
+ col_texts = row_text.split('&')
531
+ row_cells = []
532
+ for col_text in col_texts:
533
+ col_text = col_text.strip()
534
+ if col_text:
535
+ cell_children = _LatexParser(col_text, in_env=True).parse()
536
+ else:
537
+ cell_children = []
538
+ row_cells.append(cell_children)
539
+ # 确保每行至少 2 列(cases 格式)
540
+ while len(row_cells) < 2:
541
+ row_cells.append([])
542
+ if row_cells:
543
+ cell_ommls.append(row_cells)
544
+
545
+ if not cell_ommls:
546
+ return _m_run('')
547
+
548
+ # cases: 左花括号,无右定界符(设空字符串禁用默认配对标点)
549
+ return _build_matrix(cell_ommls, '{', '')
550
+
192
551
  def parse_expr(self):
193
- """解析表达式:term*"""
552
+ """解析表达式:term*。环境内遇到 & 或 \\\\ 时停止。"""
194
553
  children = []
195
554
  while self.pos < len(self.s):
196
555
  c = self.peek()
197
556
  if c is None or c == '}':
198
557
  break
199
- if c == '&': # 矩阵列分隔
558
+ if c == '&': # 环境内的列分隔,停止解析
559
+ if self._in_env:
560
+ break
200
561
  self.consume()
201
562
  children.append(_m_run('&'))
202
563
  continue
203
- if c == '\\\\': # 矩阵行分隔
564
+ if c == '\\' and self.pos + 1 < len(self.s) and self.s[self.pos + 1] == '\\':
565
+ # \\ 行分隔:环境内停止,环境外当文本
566
+ if self._in_env:
567
+ break
204
568
  self.consume()
205
569
  self.consume()
206
570
  children.append(_m_run('\\\\'))
@@ -317,6 +681,10 @@ class _LatexParser:
317
681
  self.expect('}') # consume closing brace if present
318
682
  if len(children) == 1:
319
683
  return children[0]
684
+ # 多子元素时检查是否全为普通文本 run,若是则合并避免冗余 m:d 包装
685
+ merged_text = _try_merge_text_runs(children)
686
+ if merged_text is not None:
687
+ return _m_run(merged_text)
320
688
  return _m_wrap_d(children)
321
689
 
322
690
  # LaTeX 命令 \
@@ -329,6 +697,17 @@ class _LatexParser:
329
697
  if self.peek() in (' ', '\t', '\n'):
330
698
  return _m_run(' ')
331
699
 
700
+ # LaTeX 间距字符: \, \! \: \;
701
+ if self.peek() == ',':
702
+ self.consume()
703
+ return _m_run(' ') # thin space U+2009
704
+ if self.peek() == '!':
705
+ self.consume()
706
+ return _m_run('') # negative thin space
707
+ if self.peek() == ':':
708
+ self.consume()
709
+ return _m_run(' ') # medium space (en space)
710
+
332
711
  name = self.read_name()
333
712
  if not name:
334
713
  # 转义字符如 \$ \# \% \& \_ \{ \}
@@ -337,8 +716,115 @@ class _LatexParser:
337
716
  return _m_run(esc)
338
717
  return _m_run('\\')
339
718
 
719
+ # 环境: \begin{env} ... \end{env}
720
+ if name == 'begin':
721
+ env_name = self.read_required_arg()
722
+ if env_name:
723
+ return self._parse_environment(env_name)
724
+ return _m_run('\\begin{}')
725
+ if name == 'end':
726
+ # \end{env} 由 _parse_environment 处理,不应直接出现在 parse_atom 中
727
+ # 如果出现(解析异常),回退为文本
728
+ env_name = self.read_required_arg()
729
+ return _m_run(f'\\end{{{env_name or ""}}}')
730
+
731
+ # 字体样式 \mathbf{}, \mathbb{}, \mathcal{} 等
732
+ if name in _FONT_STYLES:
733
+ arg_text = self.read_required_arg()
734
+ if arg_text is not None:
735
+ style_args = _FONT_STYLES[name]
736
+ # 解析参数内容
737
+ inner_children = _LatexParser(arg_text).parse()
738
+ # 对每个结果元素应用样式
739
+ styled = []
740
+ for child in inner_children:
741
+ if isinstance(child, str):
742
+ styled.append(child)
743
+ else:
744
+ styled.append(_apply_style_to_elem(child, style_args))
745
+ if len(styled) == 1 and not isinstance(styled[0], str):
746
+ return styled[0]
747
+ # 尝试合并纯文本 run,避免多余 m:d 包装
748
+ merged = _try_merge_text_runs(styled)
749
+ if merged is not None:
750
+ # 用合并后的文本 + 相同样式重新生成
751
+ italic = not style_args.get('normal', False) and style_args.get('italic', True)
752
+ return _m_run_with_style(merged, italic=italic,
753
+ bold=style_args.get('bold', False),
754
+ script=style_args.get('script'),
755
+ normal=style_args.get('normal', False))
756
+ return _m_wrap_d(styled)
757
+ return _m_run('\\' + name + '{}')
758
+
759
+ # 间距命令 \, \! \quad \qquad \enspace
760
+ if name == ',' or name == 'thinspace':
761
+ return _m_run(' ') # thin space U+2009
762
+ if name == '!' or name == 'negthinspace':
763
+ return _m_run('') # 零宽负间距
764
+ if name == 'quad':
765
+ return _m_run(' ') # em space U+2003
766
+ if name == 'qquad':
767
+ return _m_run('  ') # 2em
768
+ if name == 'enspace':
769
+ return _m_run(' ') # en space U+2002
770
+
771
+ # 扩展重音 \overrightarrow{...} \overleftarrow{...}
772
+ if name == 'overrightarrow':
773
+ body = self.read_required_arg()
774
+ if body is not None:
775
+ acc = _m_elem('acc')
776
+ accPr = _m_elem('accPr')
777
+ chr_elem = _m_elem('chr')
778
+ chr_elem.set(_m_qn('val'), '→')
779
+ accPr.append(chr_elem)
780
+ acc.append(accPr)
781
+ e = _m_elem('e')
782
+ e.append(_m_run(body))
783
+ acc.append(e)
784
+ return acc
785
+ return _m_run('\\overrightarrow{}')
786
+ if name == 'overleftarrow':
787
+ body = self.read_required_arg()
788
+ if body is not None:
789
+ acc = _m_elem('acc')
790
+ accPr = _m_elem('accPr')
791
+ chr_elem = _m_elem('chr')
792
+ chr_elem.set(_m_qn('val'), '←')
793
+ accPr.append(chr_elem)
794
+ acc.append(accPr)
795
+ e = _m_elem('e')
796
+ e.append(_m_run(body))
797
+ acc.append(e)
798
+ return acc
799
+ return _m_run('\\overleftarrow{}')
800
+
801
+ # 颜色命令(简化处理:转为普通文本,颜色属性在 OMML 中支持有限)
802
+ # \textcolor{color}{text} → 忽略颜色,保留文本
803
+ if name == 'textcolor':
804
+ color_name = self.read_required_arg()
805
+ text_arg = self.read_required_arg()
806
+ if text_arg is not None:
807
+ return _m_text_run(text_arg)
808
+ return _m_run('\\textcolor{}{}')
809
+ if name == 'color':
810
+ # \color{name} 设置后续颜色,无法精确映射到 OMML,忽略
811
+ _ = self.read_required_arg()
812
+ return _m_run('') # 忽略颜色标记
813
+
340
814
  # n-ary 大运算符(sum, prod, int 等)
341
815
  if name in BIG_OPS:
816
+ # 先检查是否有下上限
817
+ saved = self.pos
818
+ has_limits = False
819
+ self.skip_spaces()
820
+ if self.peek() == '_' or self.peek() == '^':
821
+ has_limits = True
822
+ self.pos = saved # 回退
823
+
824
+ # 无上下限:直接输出 Unicode 字符,避免 m:nary 占位符
825
+ if not has_limits:
826
+ return _m_run(BIG_OPS[name])
827
+
342
828
  nary = _m_elem('nary')
343
829
  naryPr = _m_elem('naryPr')
344
830
  chr_elem = _m_elem('chr')
@@ -349,9 +835,14 @@ class _LatexParser:
349
835
  naryPr.append(limLoc)
350
836
  nary.append(naryPr)
351
837
 
838
+ # 记录是否有上下限,无则隐藏占位符
839
+ has_sub = False
840
+ has_sup = False
841
+
352
842
  # 读取下上限
353
843
  if self.peek() == '_':
354
844
  self.consume()
845
+ has_sub = True
355
846
  sub_arg = self.parse_atom()
356
847
  if sub_arg is not None:
357
848
  sub = _m_elem('sub')
@@ -360,12 +851,23 @@ class _LatexParser:
360
851
 
361
852
  if self.peek() == '^':
362
853
  self.consume()
854
+ has_sup = True
363
855
  sup_arg = self.parse_atom()
364
856
  if sup_arg is not None:
365
857
  sup = _m_elem('sup')
366
858
  sup.append(sup_arg if not isinstance(sup_arg, str) else _m_run(sup_arg))
367
859
  nary.append(sup)
368
860
 
861
+ # 无上下限时隐藏占位符
862
+ if not has_sub:
863
+ subHide = _m_elem('subHide')
864
+ subHide.set(_m_qn('val'), '1')
865
+ naryPr.append(subHide)
866
+ if not has_sup:
867
+ supHide = _m_elem('supHide')
868
+ supHide.set(_m_qn('val'), '1')
869
+ naryPr.append(supHide)
870
+
369
871
  e = _m_elem('e')
370
872
  # 读取 integrand / summand
371
873
  rest = self.parse_expr()
@@ -375,12 +877,21 @@ class _LatexParser:
375
877
  nary.append(e)
376
878
  return nary
377
879
 
378
- # 分数 \frac{num}{den}
379
- if name == 'frac':
880
+ # 分数 \frac{num}{den} 及展示变体 \dfrac \tfrac
881
+ if name in ('frac', 'dfrac', 'tfrac'):
380
882
  num_text = self.read_required_arg()
381
883
  den_text = self.read_required_arg()
382
884
  if num_text is not None and den_text is not None:
383
885
  f = _m_elem('f')
886
+ # fPr: 分数类型区分显示/行内/小分数
887
+ fPr = _m_elem('fPr')
888
+ type_elem = _m_elem('type')
889
+ if name == 'dfrac' or (name == 'frac' and self._display):
890
+ type_elem.set(_m_qn('val'), 'bar') # 显示分数(大)
891
+ elif name == 'tfrac' or name == 'frac':
892
+ type_elem.set(_m_qn('val'), 'skw') # 小分数(行内)
893
+ fPr.append(type_elem)
894
+ f.append(fPr)
384
895
  num = _m_elem('num')
385
896
  num_parsed = _LatexParser(num_text).parse_expr()
386
897
  for item in num_parsed:
@@ -409,6 +920,13 @@ class _LatexParser:
409
920
  deg.append(_m_run(deg_text))
410
921
  if deg is not None:
411
922
  rad.append(deg)
923
+ else:
924
+ # 无 [n] 时隐藏 degree 占位符(Word 默认显示空位)
925
+ radPr = _m_elem('radPr')
926
+ degHide = _m_elem('degHide')
927
+ degHide.set(_m_qn('val'), '1')
928
+ radPr.append(degHide)
929
+ rad.append(radPr)
412
930
  e = _m_elem('e')
413
931
  body = self.read_required_arg()
414
932
  if body is not None:
@@ -446,11 +964,13 @@ class _LatexParser:
446
964
  # 定界符 \left( \right) \left[ \right] 等
447
965
  if name == 'left':
448
966
  delim_char = self.consume()
449
- # 暂时用 d 包装,标记左定界符
967
+ # 处理 \left. (不可见定界符)
968
+ if delim_char == '.':
969
+ delim_char = ''
450
970
  d = _m_elem('d')
451
971
  dPr = _m_elem('dPr')
452
972
  begChr = _m_elem('begChr')
453
- begChr.set(_m_qn('val'), delim_char or '(')
973
+ begChr.set(_m_qn('val'), delim_char or '')
454
974
  dPr.append(begChr)
455
975
  d.append(dPr)
456
976
  # 读取直到 \right
@@ -462,8 +982,10 @@ class _LatexParser:
462
982
  name2 = self.read_name()
463
983
  if name2 == 'right':
464
984
  right_delim = self.consume()
985
+ if right_delim == '.':
986
+ right_delim = ''
465
987
  endChr = _m_elem('endChr')
466
- endChr.set(_m_qn('val'), right_delim or ')')
988
+ endChr.set(_m_qn('val'), right_delim or '')
467
989
  dPr.append(endChr)
468
990
  break
469
991
  else:
@@ -472,9 +994,12 @@ class _LatexParser:
472
994
  body.append(self.parse_term())
473
995
  else:
474
996
  body.append(self.parse_term())
997
+ # 内容必须包在 m:e 中
998
+ e = _m_elem('e')
475
999
  for b in body:
476
1000
  if b is not None:
477
- d.append(b if not isinstance(b, str) else _m_run(b))
1001
+ e.append(b if not isinstance(b, str) else _m_run(b))
1002
+ d.append(e)
478
1003
  return d
479
1004
 
480
1005
  # 花括号(literal braces via \{ \})
@@ -516,9 +1041,15 @@ def latex_to_omml(latex, display=False):
516
1041
  返回:
517
1042
  OMML 顶级元素(m:oMath 或 m:oMathPara),可直接插入段落 XML
518
1043
  """
519
- parser = _LatexParser(latex)
1044
+ parser = _LatexParser(latex, display=display)
520
1045
  children = parser.parse()
521
1046
 
1047
+ # 如果解析结果已经有 m:oMathPara(align 环境),直接返回
1048
+ if len(children) == 1:
1049
+ child = children[0]
1050
+ if hasattr(child, 'tag') and child.tag == f'{{{NSM}}}oMathPara':
1051
+ return child
1052
+
522
1053
  omml = _m_elem('oMath')
523
1054
  for c in children:
524
1055
  omml.append(c if not isinstance(c, str) else _m_run(c))
@@ -723,21 +1254,32 @@ def set_outline_level(paragraph, level):
723
1254
  def split_inline_math(text):
724
1255
  r"""分割段落文本中的行内公式 $...$,返回 segment 列表。
725
1256
  每个 segment: {'type': 'text'|'math', 'content': str}
726
- 支持 \$ 转义,不支持嵌套。
1257
+ 支持 \$ 转义(在公式内容和普通文本中),不支持嵌套。
1258
+ 使用字符扫描器而非正则,正确处理 \$ 内部 $。
727
1259
  """
728
1260
  if not text:
729
1261
  return [{'type': 'text', 'content': text}]
730
1262
  segments = []
731
- # 匹配 $...$ 但不匹配 $$
732
- pattern = re.compile(r'(?<!\\)\$([^$]+?)(?<!\\)\$')
733
- last_end = 0
734
- for m in pattern.finditer(text):
735
- if m.start() > last_end:
736
- segments.append({'type': 'text', 'content': text[last_end:m.start()]})
737
- segments.append({'type': 'math', 'content': m.group(1)})
738
- last_end = m.end()
739
- if last_end < len(text):
740
- segments.append({'type': 'text', 'content': text[last_end:]})
1263
+ i = 0
1264
+ last = 0
1265
+ while i < len(text):
1266
+ # 只匹配非转义的 $ 作为定界符
1267
+ if text[i] == '$' and (i == 0 or text[i - 1] != '\\'):
1268
+ # 找匹配的闭合 $
1269
+ j = i + 1
1270
+ while j < len(text):
1271
+ if text[j] == '$' and text[j - 1] != '\\':
1272
+ # 找到闭合
1273
+ if i > last:
1274
+ segments.append({'type': 'text', 'content': text[last:i]})
1275
+ segments.append({'type': 'math', 'content': text[i + 1:j]})
1276
+ last = j + 1
1277
+ i = j
1278
+ break
1279
+ j += 1
1280
+ i += 1
1281
+ if last < len(text):
1282
+ segments.append({'type': 'text', 'content': text[last:]})
741
1283
  return segments if segments else [{'type': 'text', 'content': text}]
742
1284
 
743
1285
 
@@ -1125,6 +1667,27 @@ def generate_docx(nodes, output_path, title_text=None):
1125
1667
 
1126
1668
  elif t == 'display_math':
1127
1669
  # 行间公式:上下各空一行,公式居中,编号右对齐
1670
+ try:
1671
+ omml = latex_to_omml(node['text'], display=True)
1672
+ except Exception:
1673
+ omml = None
1674
+
1675
+ # 多行公式(align 环境返回 m:oMathPara):同一段落,m:oMath 用 <w:br/> 分隔
1676
+ if omml is not None and omml.tag == f'{{{NSM}}}oMathPara':
1677
+ add_empty_para(doc)
1678
+ p_align = doc.add_paragraph()
1679
+ p_align.alignment = WD_ALIGN_PARAGRAPH.CENTER
1680
+ lines = list(omml) # m:oMath 子元素
1681
+ for idx, line_omml in enumerate(lines):
1682
+ if idx > 0:
1683
+ # 行间插入换行符
1684
+ br_run = p_align.add_run()
1685
+ br = OxmlElement('w:br')
1686
+ br_run._r.append(br)
1687
+ p_align._element.append(line_omml)
1688
+ add_empty_para(doc)
1689
+ continue
1690
+
1128
1691
  add_empty_para(doc)
1129
1692
  p = doc.add_paragraph()
1130
1693
  # 设置段落 tab stops:居中 + 右对齐
@@ -1146,10 +1709,9 @@ def generate_docx(nodes, output_path, title_text=None):
1146
1709
  tab1 = OxmlElement('w:tab')
1147
1710
  run_t1._r.append(tab1)
1148
1711
  # 插入 OMML 行内公式(m:oMath 在 w:r 同级参与排版)
1149
- try:
1150
- omml = latex_to_omml(node['text'], display=False)
1712
+ if omml is not None:
1151
1713
  p._element.append(omml)
1152
- except Exception:
1714
+ else:
1153
1715
  run_fb = p.add_run(node['text'])
1154
1716
  set_run_font(run_fb, '宋体', en_font='Times New Roman', size_pt=10.5)
1155
1717
  run_fb.font.italic = True