@cliven/mddocx 1.0.3 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/README.md +36 -2
- package/package.json +17 -5
- package/skills/mddoc/SKILL.md +11 -2
- package/skills/mddoc/evals/test-latex-coverage.md +261 -0
- package/skills/mddoc/scripts/md2docx.py +620 -26
- package/skills/mddoc/scripts/__pycache__/md2docx.cpython-312.pyc +0 -0
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
[](https://github.com/Trisia/mddocx/actions/workflows/release.yml)
|
|
12
12
|
[](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
|

|
|
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
|
|
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.
|
|
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": "
|
|
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": [
|
|
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
|
}
|
package/skills/mddoc/SKILL.md
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
-
#
|
|
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
|
-
|
|
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))
|
|
@@ -566,8 +1097,40 @@ def add_empty_para(doc):
|
|
|
566
1097
|
# ============================================================
|
|
567
1098
|
|
|
568
1099
|
def download_image(url):
|
|
569
|
-
"""
|
|
1100
|
+
"""下载或复制图片到临时文件,返回路径。失败返回 None。
|
|
1101
|
+
|
|
1102
|
+
支持:
|
|
1103
|
+
- HTTP/HTTPS URL:requests 下载
|
|
1104
|
+
- 本地文件路径:直接复制到临时文件
|
|
1105
|
+
- base64 data URI:解码保存
|
|
1106
|
+
"""
|
|
570
1107
|
import requests
|
|
1108
|
+
import shutil
|
|
1109
|
+
|
|
1110
|
+
# 1. 本地文件路径(相对于工作目录的路径)
|
|
1111
|
+
if os.path.exists(url):
|
|
1112
|
+
suffix = os.path.splitext(url)[1] or '.png'
|
|
1113
|
+
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
|
|
1114
|
+
shutil.copy2(url, tmp.name)
|
|
1115
|
+
tmp.close()
|
|
1116
|
+
return tmp.name
|
|
1117
|
+
|
|
1118
|
+
# 2. base64 data URI
|
|
1119
|
+
if url.startswith('data:'):
|
|
1120
|
+
import base64
|
|
1121
|
+
# 格式: data:image/png;base64,xxxx 或 data:image/png,xxxx
|
|
1122
|
+
header, data = url.split(',', 1)
|
|
1123
|
+
is_base64 = ';base64' in header
|
|
1124
|
+
suffix = '.png'
|
|
1125
|
+
mime_match = re.match(r'data:image/(\w+)', header)
|
|
1126
|
+
if mime_match:
|
|
1127
|
+
suffix = '.' + mime_match.group(1)
|
|
1128
|
+
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
|
|
1129
|
+
tmp.write(base64.b64decode(data) if is_base64 else data.encode())
|
|
1130
|
+
tmp.close()
|
|
1131
|
+
return tmp.name
|
|
1132
|
+
|
|
1133
|
+
# 3. HTTP/HTTPS URL
|
|
571
1134
|
headers = {'User-Agent': 'Mozilla/5.0 (compatible; mddoc-converter/1.0)'}
|
|
572
1135
|
try:
|
|
573
1136
|
resp = requests.get(url, headers=headers, timeout=30)
|
|
@@ -691,21 +1254,32 @@ def set_outline_level(paragraph, level):
|
|
|
691
1254
|
def split_inline_math(text):
|
|
692
1255
|
r"""分割段落文本中的行内公式 $...$,返回 segment 列表。
|
|
693
1256
|
每个 segment: {'type': 'text'|'math', 'content': str}
|
|
694
|
-
支持 \$
|
|
1257
|
+
支持 \$ 转义(在公式内容和普通文本中),不支持嵌套。
|
|
1258
|
+
使用字符扫描器而非正则,正确处理 \$ 内部 $。
|
|
695
1259
|
"""
|
|
696
1260
|
if not text:
|
|
697
1261
|
return [{'type': 'text', 'content': text}]
|
|
698
1262
|
segments = []
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
if
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
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:]})
|
|
709
1283
|
return segments if segments else [{'type': 'text', 'content': text}]
|
|
710
1284
|
|
|
711
1285
|
|
|
@@ -1093,6 +1667,27 @@ def generate_docx(nodes, output_path, title_text=None):
|
|
|
1093
1667
|
|
|
1094
1668
|
elif t == 'display_math':
|
|
1095
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
|
+
|
|
1096
1691
|
add_empty_para(doc)
|
|
1097
1692
|
p = doc.add_paragraph()
|
|
1098
1693
|
# 设置段落 tab stops:居中 + 右对齐
|
|
@@ -1114,10 +1709,9 @@ def generate_docx(nodes, output_path, title_text=None):
|
|
|
1114
1709
|
tab1 = OxmlElement('w:tab')
|
|
1115
1710
|
run_t1._r.append(tab1)
|
|
1116
1711
|
# 插入 OMML 行内公式(m:oMath 在 w:r 同级参与排版)
|
|
1117
|
-
|
|
1118
|
-
omml = latex_to_omml(node['text'], display=False)
|
|
1712
|
+
if omml is not None:
|
|
1119
1713
|
p._element.append(omml)
|
|
1120
|
-
|
|
1714
|
+
else:
|
|
1121
1715
|
run_fb = p.add_run(node['text'])
|
|
1122
1716
|
set_run_font(run_fb, '宋体', en_font='Times New Roman', size_pt=10.5)
|
|
1123
1717
|
run_fb.font.italic = True
|
|
Binary file
|