dsh-docx2md 0.0.0-stage → 0.6.4
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/LICENSE +21 -0
- package/README.md +879 -2
- package/cordis.patch.yml +7 -0
- package/icon.svg +15 -0
- package/lib/index.js +611 -0
- package/lib/toolkit.js +157 -0
- package/package.json +54 -4
- package/py/build.py +1165 -0
- package/py/canvas.py +704 -0
- package/py/docstyle.py +421 -0
- package/py/docx_scan.py +333 -0
- package/py/docxfile.py +90 -0
- package/py/extract.py +1015 -0
- package/py/imgformula.py +283 -0
- package/py/imgsize.py +121 -0
- package/py/omml.py +336 -0
- package/py/render.py +323 -0
- package/py/rich.py +308 -0
- package/py/validate.py +638 -0
- package/skills/docx2md/SKILL.md +447 -0
package/README.md
CHANGED
|
@@ -1,3 +1,880 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-docx2md
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
把含公式的 Word 文档转成带 LaTeX 的 Markdown —— 跨平台,保留公式编号与结构。
|
|
4
|
+
**OLE 公式(Equation Editor / MathType)、Word 原生公式(OMML,新版 Word 的
|
|
5
|
+
默认格式)、图片形式的公式(截图/扫描)三种载体都处理。**
|
|
6
|
+
|
|
7
|
+
A DeepSeek Harness plugin that converts Word documents with embedded equations —
|
|
8
|
+
legacy OLE (MTEF), native OMML, and image-embedded formulas — into Markdown with
|
|
9
|
+
LaTeX, preserving numbering and document structure.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## 三种载体,三条路
|
|
14
|
+
|
|
15
|
+
| | OLE 公式 | OMML(Word 原生) | 图片公式 |
|
|
16
|
+
|---|---|---|---|
|
|
17
|
+
| 谁在用 | Equation Editor / MathType;WPS 公式的默认载体 | Word 2007+ 插入公式的默认格式;2016 后手敲 LaTeX 存下来也是它 | 截图粘贴、扫描页、从 PDF 拷贝 |
|
|
18
|
+
| 存在哪 | 私有二进制(MTEF)包在 `oleObjectN.bin` | 结构化 XML(`m:oMath`) | 一张位图(`w:drawing` / `v:pict`) |
|
|
19
|
+
| 怎么转 | 渲染成 PNG → 视觉模型逐张读图转写 | **extract 阶段确定性转成 LaTeX**,不读图 | **无法自动转换**(像素无结构可解);落成占位符,读 `media/` 原件转写,键 `"img:文件名"` |
|
|
20
|
+
| 质量 | 取决于读图;有校验器与配对告警兜底 | 22 类结构逐一测试(分式/上下标/根式/矩阵/大运算符/重音/…) | 取决于读图;候选按形状启发式(扁条行内图),清单带证据供人过目 |
|
|
21
|
+
| 需要人工 | 是(第 3 步) | 否(不满意可覆盖,见下) | 是(第 3.5 步) |
|
|
22
|
+
| 不处理会怎样 | 报 T9 未解析 | —— | **不丢**:build 落成可见图片引用(`![图片公式 …]`),T11 查断链 |
|
|
23
|
+
|
|
24
|
+
纯 OMML 文档全程无需读图:`docx2md_build` 用**空的** latex 表也能产出完整
|
|
25
|
+
Markdown(OMML 自动预填)。混排文档里只有 OLE 与图片那部分需要视觉转写。
|
|
26
|
+
|
|
27
|
+
## 为什么 OLE 需要看图
|
|
28
|
+
|
|
29
|
+
普通的 `pandoc doc.docx -o doc.md` 遇到 Word 的 OLE 公式基本无能为力:那是
|
|
30
|
+
WPS / Word 的私有二进制格式(MTEF),解出来必然失真。
|
|
31
|
+
|
|
32
|
+
本插件对 OLE 公式的方案只有一句话:
|
|
33
|
+
|
|
34
|
+
> **以渲染出的位图为准,不以解析出的字符为准。**
|
|
35
|
+
|
|
36
|
+
实测同一份 145 个公式的文档,直接解 MTEF:
|
|
37
|
+
|
|
38
|
+
| 公式 | 看图应有的字符量 | MTEF 解出 |
|
|
39
|
+
|---|---|---|
|
|
40
|
+
| #27 | 151(5 行矩阵式) | `βP` — 2 个字符 |
|
|
41
|
+
| #30 | 150(5 行) | `u=dr` — 4 个字符 |
|
|
42
|
+
| #6 | 5 个等式链 | `α=-∇×βP` |
|
|
43
|
+
|
|
44
|
+
**它不会报错,只是安静地给你一个残缺结果。** 这是最危险的一类失败。
|
|
45
|
+
所以本插件的设计原则是:渲染失败就明确失败,绝不降级到字符解码。
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## 安装
|
|
50
|
+
|
|
51
|
+
DSH 的 profile 用 **pnpm** 管理,`node_modules` 是 pnpm 管的(`nodeLinker: hoisted`)。
|
|
52
|
+
**不要手工往 `node_modules` 里拷目录**——下次 `pnpm install` 会把没有写进
|
|
53
|
+
lockfile 的目录删掉。
|
|
54
|
+
|
|
55
|
+
本包已发布到 npm([`dsh-docx2md@0.6.4`](https://www.npmjs.com/package/dsh-docx2md)),
|
|
56
|
+
**日常使用直接装正式版本**,不依赖本地源码:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
cd ~/.dsh/profiles/desktop
|
|
60
|
+
pnpm add dsh-docx2md@0.6.4 # 正式 npm 包(推荐,会写进 pnpm-lock.yaml)
|
|
61
|
+
|
|
62
|
+
# 或 DSH 安装器一条命令(等价于上面的 pnpm add)
|
|
63
|
+
dsh plugin --profile desktop add dsh-docx2md@0.6.4
|
|
64
|
+
|
|
65
|
+
# 或装版本固定的 GitHub Release tarball(与 npm 包字节一致,不需要 npm)
|
|
66
|
+
dsh plugin --profile desktop add https://github.com/stallenlu/dsh-docx2md/releases/download/v0.6.4/dsh-docx2md-0.6.4.tgz
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
升级时把 `@0.6.4` 换成新版本号即可。
|
|
70
|
+
|
|
71
|
+
### 声明的两处
|
|
72
|
+
|
|
73
|
+
`package.json` 里有两件事都要做:
|
|
74
|
+
|
|
75
|
+
```jsonc
|
|
76
|
+
{
|
|
77
|
+
"dependencies": { "dsh-docx2md": "0.6.4" },
|
|
78
|
+
"dsh": { "profile": { "bundles": [ /* ... */, "dsh-docx2md" ] } }
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
**只需加进 `bundles`**。本包的 `package.json` 已声明
|
|
83
|
+
`dsh.bundle.patch: ./cordis.patch.yml`,DSH 会自动读取它并把插件插入。
|
|
84
|
+
**不要再往 profile 的 `cordis.patch.yml` 里手写 `insert`** —— 那会导致同一插件被
|
|
85
|
+
注册两次。
|
|
86
|
+
|
|
87
|
+
`dependencies` 里写的是 **npm 版本号**(`pnpm add dsh-docx2md@0.6.4` 会自动写入这一行和 `pnpm-lock.yaml`);不要再写成 `file:` 路径或 `github:` 地址。
|
|
88
|
+
|
|
89
|
+
### 让配置改完立即生效
|
|
90
|
+
|
|
91
|
+
profile 的 `package.json` 里加上:
|
|
92
|
+
|
|
93
|
+
```jsonc
|
|
94
|
+
"dsh": { "profile": { "patchReload": "live" } }
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
没有这一项时,`bundles` 的变更要**重启 DSH 进程**才生效。
|
|
98
|
+
注意:技能列表走另一套机制(`dsh-skill-filesystem` 用 chokidar 监听文件),
|
|
99
|
+
所以可能出现"技能已出现、工具还没出现"的中间状态——这不是 bug,重启即可。
|
|
100
|
+
|
|
101
|
+
### 本地开发(从源码安装)
|
|
102
|
+
|
|
103
|
+
只有改本插件代码做开发时才需要这一节;日常使用请直接装上面的 npm 正式包。
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
# 1. 把包放到 profile 外部(file: 依赖不能指向 node_modules 内部,会成自环)
|
|
107
|
+
cp -r dsh-docx2md ~/.dsh/packages/dsh-docx2md
|
|
108
|
+
|
|
109
|
+
# 2. 在 profile 里用 pnpm 安装(会写进 pnpm-lock.yaml)
|
|
110
|
+
cd ~/.dsh/profiles/desktop
|
|
111
|
+
pnpm add file:~/../packages/dsh-docx2md
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Windows 上路径要写成 `pnpm add file:C:/Users/<你>/.dsh/packages/dsh-docx2md`。
|
|
115
|
+
也可以直接从 Git 仓库装开发中的版本:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
cd ~/.dsh/profiles/desktop
|
|
119
|
+
pnpm add github:<你的账号>/dsh-docx2md
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
再把包名加进 `bundles`。装好之后改 `~/.dsh/packages/dsh-docx2md` 里的文件即可——
|
|
123
|
+
pnpm 建的是**硬链接**,改动会立即反映到 `node_modules`(若用编辑器原子写入导致
|
|
124
|
+
硬链接断开,重跑一次 `pnpm install` 即可)。`file:` / `github:` 这类依赖只用于开发,
|
|
125
|
+
正式环境请用 `dsh-docx2md@0.6.4`。
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
### 更新
|
|
130
|
+
|
|
131
|
+
**从 npm 安装时**,升级就是换版本号(lock 里记的是版本号 + integrity):
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
cd ~/.dsh/profiles/desktop
|
|
135
|
+
pnpm add dsh-docx2md@<新版本> # 或 pnpm update dsh-docx2md
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
改完重启 DSH,跑一次 `docx2md_inspect` 确认行为符合预期。
|
|
139
|
+
|
|
140
|
+
> 下面「更新 / sync-check」几段只适用于**本地开发(`file:` 依赖)**;从 npm 安装无需这些步骤。
|
|
141
|
+
|
|
142
|
+
`file:` 依赖在 lock 里记为 `type: directory`(不是版本号),所以 pnpm **不按版本判断
|
|
143
|
+
是否需要更新**——它只是指向那个目录。因此“更新”要分三种情况:
|
|
144
|
+
|
|
145
|
+
| 情况 | 需要做什么 | 实测 |
|
|
146
|
+
|---|---|---|
|
|
147
|
+
| 只改代码,版本号不变 | `pnpm install` + 重启 DSH | node_modules 立即同步(硬链接) |
|
|
148
|
+
| 改了版本号 | 同上 | package.json 随硬链接自动同步 |
|
|
149
|
+
| 文件被原子写入(编辑器保存、`git checkout`、`cp` 覆盖) | **必须 `pnpm install`** | 硬链接断开,node_modules 保留**旧代码** |
|
|
150
|
+
|
|
151
|
+
**第三条是最容易踩的坑**:inode 变了,硬链接断了,`node_modules` 里还是旧代码。
|
|
152
|
+
没有报错,工具照常运行,只是行为没变——你在源目录看到的是新代码,实际加载的不是。
|
|
153
|
+
|
|
154
|
+
标准更新流程:
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
# 1. 改代码(改 ~/.dsh/packages/dsh-docx2md 或你的源码目录后同步过去)
|
|
158
|
+
# 2. 修复可能断开的硬链接(1-2 秒,不要省)
|
|
159
|
+
cd ~/.dsh/profiles/desktop && pnpm install
|
|
160
|
+
# 3. 重启 DSH
|
|
161
|
+
# 4. 验证:跑一次 docx2md_inspect 确认行为符合预期
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
`test/update.sh` 会把这四种情况都在一次性环境里复现一遍。
|
|
165
|
+
|
|
166
|
+
#### 改完先跑 sync-check.sh
|
|
167
|
+
|
|
168
|
+
上面三步(同步 → 修链接 → 重启)只要漏一步,跑出来的“验证通过”就是假的。
|
|
169
|
+
开发时改的是源码目录,DSH 加载的是 `node_modules` 里那份,中间隔着复制和硬链接
|
|
170
|
+
两道;任何一道断了都不报错,只是行为不变。
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
./test/sync-check.sh # 检查
|
|
174
|
+
./test/sync-check.sh --fix # 不一致时自动同步源码 → 安装副本
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
它检查四件事,并用退出码区分严重程度:
|
|
178
|
+
|
|
179
|
+
| 退出码 | 含义 | 该怎么办 |
|
|
180
|
+
|---|---|---|
|
|
181
|
+
| 0 | 三处一致,且进程加载的是最新代码 | 可以跑真机验证 |
|
|
182
|
+
| 1 | 代码不同步(副本落后 / 硬链接断裂) | `--fix`,必要时 `pnpm install` |
|
|
183
|
+
| 2 | 三处代码已一致,但 DSH 进程是旧版本 | 重启 DSH,之后才有意义 |
|
|
184
|
+
|
|
185
|
+
分开 1 和 2 是有意的:退出码 2 表示「代码没问题,只差重启」,CI 不该因此报失败;
|
|
186
|
+
退出码 1 才是真的代码不一致。
|
|
187
|
+
|
|
188
|
+
脚本以 `package.json` 的 `files` 字段为准来列清单(`test/` 不随包安装,不参与比对)。
|
|
189
|
+
它也会读宿主进程启动时间,与最新文件修改时间对比,因此能直接告诉你「要不要重启」。
|
|
190
|
+
|
|
191
|
+
---
|
|
192
|
+
|
|
193
|
+
### 卸载
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
cd ~/.dsh/profiles/desktop
|
|
197
|
+
pnpm remove dsh-docx2md
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
然后从 `package.json` 的 `dsh.profile.bundles` 里删掉 `"dsh-docx2md"`,重启 DSH。
|
|
201
|
+
若曾用 `file:` 本地开发安装,`~/.dsh/packages/dsh-docx2md` 源码副本可留着下次用,也可以删掉;从 npm 安装则没有这个目录。
|
|
202
|
+
|
|
203
|
+
验证是否干净:`node_modules/dsh-docx2md` 不存在、`pnpm-lock.yaml` 无记录、
|
|
204
|
+
`bundles` 无条目。本仓库 `test/lifecycle.sh` 就是跑这套闭环的。
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
## 运行环境
|
|
209
|
+
|
|
210
|
+
| 依赖 | 用途 | 必需 |
|
|
211
|
+
|---|---|---|
|
|
212
|
+
| Python 3.9+ | 跑 py/ 下的脚本 | ✅ |
|
|
213
|
+
| `olefile` | 解 OLE 容器、取 Equation Native 流 | 自动经 uvx 安装 |
|
|
214
|
+
| `pypdf` | **预留,当前未使用**(见「已知限制」)| 自动经 uvx 安装 |
|
|
215
|
+
| `uv`(可选) | 免手动装 Python 依赖 | 推荐 |
|
|
216
|
+
|
|
217
|
+
**渲染引擎**(用于把 WMF/EMF 公式图转 PNG),按顺序自动探测:
|
|
218
|
+
|
|
219
|
+
| 平台 | 引擎 | 说明 |
|
|
220
|
+
|---|---|---|
|
|
221
|
+
| Windows(桌面 App) | GDI+ (`System.Drawing`) | 原生支持;**实测 127 张图 4.9 s** |
|
|
222
|
+
| WSL | 调宿主 Windows 的 GDI+ | 自动转换路径为 UNC |
|
|
223
|
+
| Linux / macOS | LibreOffice (`soffice`) | 需自行安装 |
|
|
224
|
+
| 兜底 | Pillow + libwmf | 少见,有就用 |
|
|
225
|
+
|
|
226
|
+
全都没有时,`docx2md_inspect` 会明确告诉你,`docx2md_extract` 会保留原始
|
|
227
|
+
矢量图、把 `render.ok` 置为 false——不会假装成功。
|
|
228
|
+
|
|
229
|
+
**画布渲染**(用于把流程图 `v:group` 整块转成 PNG),需要无头浏览器:
|
|
230
|
+
|
|
231
|
+
| 平台 | 引擎 | 说明 |
|
|
232
|
+
|---|---|---|
|
|
233
|
+
| Windows / WSL | Edge 或 Chrome 的 `--headless` | 自动探测 `msedge` / `chrome` / 常见安装路径 |
|
|
234
|
+
|
|
235
|
+
画布有两种表示,都要整块渲染:
|
|
236
|
+
|
|
237
|
+
| docx 里的写法 | 是什么 | 为什么不能逐张抽 |
|
|
238
|
+
|---|---|---|
|
|
239
|
+
| VML `v:group` | 流程图(框/菱形/箭头/标签) | 文字层表达不了「这些框怎么连」 |
|
|
240
|
+
| DrawingML `wpc` | 图版(3×2 网格等) | 每张图带 `a:off`/`a:ext` 绝对坐标,逐张抽会把网格压成竖排 |
|
|
241
|
+
| VML `v:group`(纯多图) | 同上,只是老写法 | 同上 |
|
|
242
|
+
|
|
243
|
+
同一张图版在 `mc:AlternateContent` 里有 `Choice`(`wpc`,带坐标)和
|
|
244
|
+
`Fallback`(`v:group`,只有顺序)两份,**只取 Choice**:那份才有位置信息,
|
|
245
|
+
且两者是同一份内容,都读会重复出图。
|
|
246
|
+
|
|
247
|
+
没有可用浏览器时,画布**不会静默消失**:`summary.json` 里
|
|
248
|
+
`canvases_rendered` 会小于 `canvases`,并给出 `canvas_warning` 指明哪一块、
|
|
249
|
+
为什么失败。
|
|
250
|
+
|
|
251
|
+
「渲染成功」的判据不是「PNG 存在且非空」——浏览器打开坏 URL 时会把
|
|
252
|
+
一张「未找到文件」错误页截成 PNG,尺寸、字节数、退出码全都正常。
|
|
253
|
+
所以截图时会同时 `--dump-dom`,DOM 里必须能找到 SVG 根上的
|
|
254
|
+
`data-docx2md="canvas"` 标记,否则判失败。
|
|
255
|
+
|
|
256
|
+
---
|
|
257
|
+
|
|
258
|
+
## 用法
|
|
259
|
+
|
|
260
|
+
### 在 Agent 里
|
|
261
|
+
|
|
262
|
+
直接说:
|
|
263
|
+
|
|
264
|
+
> 把 `论文.docx` 转成 Markdown,公式要保留。
|
|
265
|
+
|
|
266
|
+
Agent 会加载 `docx2md` 技能,按 inspect → extract → 读图 → build 的顺序执行。
|
|
267
|
+
|
|
268
|
+
### 三个工具
|
|
269
|
+
|
|
270
|
+
```
|
|
271
|
+
docx2md_inspect(path)
|
|
272
|
+
```
|
|
273
|
+
只读扫描。返回段落数、公式数(分 `formulas`=OLE 与 `formulas_omml`=Word 原生
|
|
274
|
+
三类:OLE / OMML / 图片公式候选)、变体分布(A 可解 / B 必须看图)、预览图格式分布、本机渲染引擎。
|
|
275
|
+
|
|
276
|
+
**它只输出事实,不给结论。** 例如缺预览图的公式,`inspect` 会给出
|
|
277
|
+
`missing_preview_context`(所在段落、段落文字、前后段),但不会替你判断
|
|
278
|
+
「这是图注标注还是正文公式」——那需要看全文,属于调用方的判断。
|
|
279
|
+
|
|
280
|
+
```
|
|
281
|
+
docx2md_extract(path, outDir?, render?)
|
|
282
|
+
```
|
|
283
|
+
产出:
|
|
284
|
+
- `paras.json` —— 正文段落,公式位置为 `⟦EQ:oleObject12.bin⟧`(OLE)、
|
|
285
|
+
`⟦EQ:omml3⟧`(Word 原生)或 `⟦EQ:img:文件名⟧`(图片公式候选)
|
|
286
|
+
- `eq_index.json` —— 每个公式的 stream / 预览图 / 字节数 / 变体 / `pairing`;
|
|
287
|
+
**OMML 条目直接带 `latex` 字段**(从 XML 结构确定性转出,无需读图)
|
|
288
|
+
- `media/` —— 原始 EMF/WMF 与图片公式的位图原件
|
|
289
|
+
- `png/` —— 可读位图(**读这个目录**)
|
|
290
|
+
- `image_inventory.json` —— 正文全部行内图片的形状证据(ratio / 尺寸 /
|
|
291
|
+
上下文段落),图片公式候选的判定依据;示意图也在这里,供人核对
|
|
292
|
+
- `textbox_labels.json` —— 流程图**文本框里的标签文字**("开始"、"结束"、
|
|
293
|
+
"输出应力、应变"…)。它们不是正文段落(混进正文会让段落数与原文对不上),
|
|
294
|
+
但也不能丢——那是文档里真实存在的文字,单独留痕供人核对
|
|
295
|
+
- `media/canvasN.png` —— 流程图/示意图画布(`v:group`)**整块**渲染的图。
|
|
296
|
+
画布里的框、菱形、箭头、标签与公式都在这一张图里;画布内的公式因此
|
|
297
|
+
**不再单独占位**(`eq_index.json` 里标 `in_canvas: true`),否则同一批
|
|
298
|
+
公式会出现两遍
|
|
299
|
+
|
|
300
|
+
```
|
|
301
|
+
docx2md_build(workDir, latex, out, suggestHeadings?, expectFormulas?)
|
|
302
|
+
```
|
|
303
|
+
把 `{"oleObject1.bin": "\\alpha", ...}` 合成 Markdown 并校验。
|
|
304
|
+
`expectFormulas` 会核对公式总数,能抓住漏转。OMML 公式无需出现在 latex 表里
|
|
305
|
+
(自动预填;给了同名键则以你给的为准)。图片公式不转写也不丢——落成
|
|
306
|
+
`` 可见引用;转写时键用 `"img:文件名"`。
|
|
307
|
+
|
|
308
|
+
### 产物落在哪、会不会留垃圾
|
|
309
|
+
|
|
310
|
+
插件**不创建系统临时目录**(不用 `mkdtemp` / `tempfile` / `TMPDIR`),所有中间产物
|
|
311
|
+
都在你给的 `outDir` 里;不给就落在 docx 同级的 `<名字>_docx2md/`。
|
|
312
|
+
|
|
313
|
+
一次转换产生 6 个条目(145 公式的论文实测 4.3 MB):
|
|
314
|
+
|
|
315
|
+
| 条目 | 内容 | 大小 |
|
|
316
|
+
|---|---|---|
|
|
317
|
+
| `paras.json` | 正文段落,公式位置为 `⟦EQ:oleObjectN.bin⟧` | ~20 KB |
|
|
318
|
+
| `eq_index.json` | 公式索引(stream / 预览图 / MTEF 变体) | ~24 KB |
|
|
319
|
+
| `image_inventory.json` | 行内图片的形状证据与上下文(图片公式候选清单) | ~1 KB |
|
|
320
|
+
| `textbox_labels.json` | 流程图文本框里的标签文字(非正文段落,单独留痕) | ~0.2 KB |
|
|
321
|
+
| `media/` | 原始矢量预览图(wmf/emf)+ 文档自带位图 | ~1.3 MB |
|
|
322
|
+
| `png/` | 渲染后的可读位图,Agent 靠它转写 LaTeX | ~3.0 MB |
|
|
323
|
+
| `summary.json` | 本次提取的统计(含 `canvases` / `canvases_rendered`) | ~0.3 KB |
|
|
324
|
+
|
|
325
|
+
**不会残留的东西**:渲染用的 `_render_report.json` 读完即删;坏输入不会留下空目录。
|
|
326
|
+
`png/` 里出现的 `.jpeg` 不是残留,而是原文档自带的位图预览(`inspect` 的
|
|
327
|
+
`media_formats` 会报出 jpeg 数量)。
|
|
328
|
+
|
|
329
|
+
**清理**:插件没有 `--clean` 参数,也不需要——`outDir` 是唯一写入点,`rm -rf` 该目录即可。
|
|
330
|
+
同一 `outDir` 复用时旧产物会被自动清掉,不会混入上一份文档的图。
|
|
331
|
+
|
|
332
|
+
**清理**:默认什么都不删(`png/` 是你事后核对公式的凭据)。想要自动清理就传
|
|
333
|
+
`cleanup: true` 给 `docx2md_build`:
|
|
334
|
+
|
|
335
|
+
```js
|
|
336
|
+
await docx2md_build({
|
|
337
|
+
workDir: 'work/', latex: 'work/latex.json', out: '论文.md',
|
|
338
|
+
expectFormulas: 145, cleanup: true,
|
|
339
|
+
})
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
`cleanup` 只在**构建完成且校验 PASS** 时才删 `media/` 与 `png/`,保留
|
|
343
|
+
`paras.json`、`eq_index.json`、`summary.json`(复现用)。校验没通过时不删——
|
|
344
|
+
那时 `png/` 正是排查「哪张图读错了」的唯一依据。返回值里的 `cleanup` 字段会
|
|
345
|
+
说明删了什么、或为什么没删。
|
|
346
|
+
|
|
347
|
+
也可以不给 `cleanup`,直接 `rm -rf` 该目录,效果一样。
|
|
348
|
+
|
|
349
|
+
### 校验器查什么
|
|
350
|
+
|
|
351
|
+
`docx2md_build` 内部会调 `validate.py`,除六条 Markdown 铁律外还查:
|
|
352
|
+
|
|
353
|
+
| 检查 | 抓住什么 |
|
|
354
|
+
|---|---|
|
|
355
|
+
| T0 | 空文件 |
|
|
356
|
+
| T1–T3 | `$$` 未独占一行、行内公式含块级环境、相邻公式块紧贴 |
|
|
357
|
+
| T7 | `$$` 奇数个(未闭合) |
|
|
358
|
+
| **T9** | **公式没解析出来**(残留 `⟦EQ:...⟧`,或 build 写的 `<!-- 未解析公式 -->`) |
|
|
359
|
+
| T8 | `expectFormulas` 给了时,公式总数不符 |
|
|
360
|
+
| T10 | 行内公式与中文紧贴(部分渲染器不认定界符) |
|
|
361
|
+
| T11 | 图片公式的引用断链(破图 = 公式又消失了) |
|
|
362
|
+
| **T12** | **强调标记不闭合**(`**应用公式 **`,闭定界符前有空白,星号按字面输出) |
|
|
363
|
+
| **T13** | **多行公式被并成一行**(预览图行高远超单行基线、转写里还留着 `\quad` 分行痕迹)。build 会自动拆回 `aligned`,这里是复查网;需要 `--media` + `--latex`,缺证据时 `docx2md_build` 用 `rowCheck` 明说跳过 |
|
|
364
|
+
| **T14** | **转写漏了内容**(预览图很宽、转写却几乎没字:图宽 ÷ 字形数 > 全体中位 6 倍)。中文与公式混排的长条最容易整段没转进来 |
|
|
365
|
+
|
|
366
|
+
T9 值得单独说:latex 表里缺的公式,build 会写成 HTML 注释。这种文档六条铁律
|
|
367
|
+
全过、注释渲染后也看不见,但正文里确实少了公式。早期版本只查铁律,会给出
|
|
368
|
+
PASS——现在判 FAIL。
|
|
369
|
+
|
|
370
|
+
### 提取阶段的自检:把「静默」变成「显式」
|
|
371
|
+
|
|
372
|
+
上面那些校验都在 build 阶段。**更早的一道闸在 extract**:
|
|
373
|
+
|
|
374
|
+
> `eq_index.json` 里每个公式,正文里都必须有对应的 `⟦EQ:…⟧` 占位符;
|
|
375
|
+
> 对不上就直接报错退出(**退出码 4**),不带缺口往下走。
|
|
376
|
+
|
|
377
|
+
为什么需要它:Word 画流程图时,公式会嵌在同一个 `w:pict` 的 `v:group` 里。
|
|
378
|
+
那个 `pict` 既是「装着公式的画布」,又长得像「公式的预览图」(里面同样有
|
|
379
|
+
`o:ole="t"` 的 `v:shape`)。实现若按「见 `o:ole="t"` 就算预览图」判断,整棵
|
|
380
|
+
`pict` 被当成一张预览图跳过,里面的 `o:OLEObject` 一并被吞掉——
|
|
381
|
+
|
|
382
|
+
> 正文照常输出,不报错、不告警,只是公式消失了。
|
|
383
|
+
|
|
384
|
+
实测在真实文档(145 个公式)里,3 个 `pict` 各装着 6–8 个公式,共 **20 个
|
|
385
|
+
公式无声消失**。校验器直到 build 阶段才对不上数(T8 FAIL),而那时原因已经
|
|
386
|
+
无从查证。
|
|
387
|
+
|
|
388
|
+
所以除了修代码,还加了这道自检:真出问题时,extract 立刻停、并把少了哪几个
|
|
389
|
+
stream 打印出来,而不是把缺口一路带到 Markdown 里。
|
|
390
|
+
|
|
391
|
+
| 退出码 | 含义 |
|
|
392
|
+
|---|---|
|
|
393
|
+
| 0 | 正常 |
|
|
394
|
+
| 2 | 参数用错(见 `--help`) |
|
|
395
|
+
| 3 | 输入不是可读的 `.docx`(**用户的问题**) |
|
|
396
|
+
| 4 | **提取自检失败:索引里有公式、正文里没有占位符**(**工具的问题**) |
|
|
397
|
+
|
|
398
|
+
3 和 4 分开是有意的:前者去查文档,后者去查「哪种容器把公式吞了」。
|
|
399
|
+
|
|
400
|
+
### 「像正文、其实不是」的三样东西
|
|
401
|
+
|
|
402
|
+
Word 文档里有三样东西长得像正文段落,其实不是。搞混它们,症状都是
|
|
403
|
+
「内容莫名少了一段/少了一个公式」,而且都不报错:
|
|
404
|
+
|
|
405
|
+
| 东西 | 在 XML 里 | 归属 | 去了哪 |
|
|
406
|
+
|---|---|---|---|
|
|
407
|
+
| 流程图文本框里的**标签文字** | `w:pict` → `v:shape` → `v:textbox` → `w:txbxContent` → `w:p` | 图的标注,不是正文 | `textbox_labels.json` |
|
|
408
|
+
| 流程图文本框里的**公式** | 同上,但框里还有 `o:OLEObject` | 图的**内容**,必须保留 | 归**最外层**段落,占位符留在正文里 |
|
|
409
|
+
| 图片公式的**预览位图** | `v:shape o:ole="t"` + `v:imagedata` | 公式的渲染图 | `media/` + `png/`,供读图转写 |
|
|
410
|
+
| 流程图**画布本身** | `v:group`(内含框/菱形/箭头/标签 + OLE) | 一整块结构,文字层表达不了 | `media/canvasN.png`,正文里 ``;画布内公式标 `in_canvas` |
|
|
411
|
+
|
|
412
|
+
三条规则:
|
|
413
|
+
|
|
414
|
+
1. **段落只数最外层的**。文本框里的段落自己也是 `w:p`,逐个遍历会把它顶出来
|
|
415
|
+
——于是同一段内容被记两遍。`inspect` 报的 `paragraphs` 只数正文段落,
|
|
416
|
+
文本框里的另计在 `paragraphs_in_textbox`(口径与 `extract` 一致)。
|
|
417
|
+
2. **公式归最外层的段落**。按最近的 `w:p` 归属,公式会跟着嵌套段落一起被
|
|
418
|
+
排除出正文——公式就这么丢了。归最外层,位置不丢,框里的公式也照样占位。
|
|
419
|
+
3. **文字与公式各归其位**。框里的文字不是正文段落(会污染段落序列),
|
|
420
|
+
框里的公式是正文内容(丢了就是丢内容)。一个走 `textbox_labels.json`,
|
|
421
|
+
一个留在正文占位符里。
|
|
422
|
+
|
|
423
|
+
> 实测:一份 145 公式的真实文档,流程图里住了 20 个公式和 6 条标签。
|
|
424
|
+
> 修之前 20 个公式被静默吞掉、6 条标签变成 6 个莫名其妙的正文段落;
|
|
425
|
+
> 修之后 145 个公式一个不少、段落数从 308 降到 293(差的 15 段正是
|
|
426
|
+
> 流程图里的标签段落)。
|
|
427
|
+
|
|
428
|
+
**同一条规则,两个工具必须用同一把尺子。** `inspect` 的
|
|
429
|
+
`missing_preview_context` 报「这一段的文字是什么」,供人判断缺图的公式该怎么
|
|
430
|
+
处理。它的取字方式若与 `extract` 不同,同一个段落索引就会各说各话——实测
|
|
431
|
+
inspect 说「段落 257 的文字是『VDDD3D程序结构图结束 t=t + dt输出应力、应变、
|
|
432
|
+
位错密度等』」,而 extract 的 `paras.json` 里同一段只有公式占位符。排查时
|
|
433
|
+
很容易被当成「文档本来就这样」。
|
|
434
|
+
|
|
435
|
+
现在两边都只取**属于本段落**的 `w:t`,文本框里的文字不算本段的文字。
|
|
436
|
+
|
|
437
|
+
### 过期标题表会吞掉公式(build 阶段的一道闸)
|
|
438
|
+
|
|
439
|
+
`headings.json` 是**按段落序号**索引的人工确认表。段落序号会随提取口径变化,
|
|
440
|
+
一旦变了,表就指错了段落——而指错的段落若恰好是一个独占的公式段,
|
|
441
|
+
**那条公式会被当成标题直接输出,公式凭空消失**。
|
|
442
|
+
|
|
443
|
+
实测踩过:段落数从 308 变 293 后,旧表把「段落 271」标成 4 级标题,而那正是
|
|
444
|
+
唯一一条 `oleObject139` 公式所在的段。build 不报错,validate 到 T8 才报
|
|
445
|
+
144 ≠ 145,而真正的错因(标题表过期)在报告里完全看不出来。
|
|
446
|
+
|
|
447
|
+
所以 `build.py` 现在会检查:标题指向的段落里若含公式占位符,直接报错退出
|
|
448
|
+
(**退出码 3**),并提示重新 `--suggest` 生成。宁可停下来问一句,也不要让
|
|
449
|
+
公式换一种方式消失。
|
|
450
|
+
|
|
451
|
+
### 完整流程示例
|
|
452
|
+
|
|
453
|
+
```js
|
|
454
|
+
// 1. 看清有什么
|
|
455
|
+
await docx2md_inspect({ path: '论文.docx' })
|
|
456
|
+
|
|
457
|
+
// 2. 提取 + 渲染
|
|
458
|
+
await docx2md_extract({ path: '论文.docx', outDir: 'work/' })
|
|
459
|
+
|
|
460
|
+
// 3. ← 这里由 Agent 逐张读 work/png/*.png,转写成 latex.json
|
|
461
|
+
// (这一步无法自动化:需要视觉理解公式结构)
|
|
462
|
+
|
|
463
|
+
// 4. 先看标题初稿
|
|
464
|
+
await docx2md_build({
|
|
465
|
+
workDir: 'work/', latex: 'work/latex.json',
|
|
466
|
+
out: '论文.md', suggestHeadings: true,
|
|
467
|
+
})
|
|
468
|
+
// 人工修订 work/headings_suggested.json → 改名 headings.json
|
|
469
|
+
|
|
470
|
+
// 5. 正式构建 + 校验
|
|
471
|
+
await docx2md_build({
|
|
472
|
+
workDir: 'work/', latex: 'work/latex.json',
|
|
473
|
+
out: '论文.md', expectFormulas: 145,
|
|
474
|
+
})
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
### 路径怎么写都行
|
|
478
|
+
|
|
479
|
+
三个工具都接受你从当前环境出发自然写出的路径,不需要考虑宿主是 Windows 还是 WSL:
|
|
480
|
+
|
|
481
|
+
| 你写的 | 在 Windows 宿主上落到 | 说明 |
|
|
482
|
+
|---|---|---|
|
|
483
|
+
| `论文.docx` | 会话工作目录下 | 相对路径 |
|
|
484
|
+
| `/home/sidady/proj/论文.docx` | WSL 视图的同一位置 | Linux 绝对路径 |
|
|
485
|
+
| `/tmp/work` | **WSL 的 `/tmp/work`** | 不是 `C:\tmp` |
|
|
486
|
+
| `/mnt/c/work` | `C:\work` | WSL 挂载点会自动换算 |
|
|
487
|
+
| `C:\work\cdot.docx` | `C:\work` | 盘符路径原样 |
|
|
488
|
+
| `\\wsl.localhost\Ubuntu\tmp` | 原样 | UNC 路径原样 |
|
|
489
|
+
|
|
490
|
+
返回的路径会自动折回你更习惯的写法:WSL 里的位置返回成 `/tmp/work`,Windows 位置返回成 `C:\work`,可以直接粘进 shell 或 `read` 工具。
|
|
491
|
+
|
|
492
|
+
> 早期版本在这里有个坑:`path.isAbsolute('/tmp/x')` 在 Windows 上返回 `true`,插件就把
|
|
493
|
+
> 字符串原样交给了 Python,而 Python 按盘符根解析成了 `C:\tmp\x`。现在一律先由 Node
|
|
494
|
+
> 按会话工作目录的卷归一化,不再透传未归一化的字符串。回归测试见 `test/paths.mjs`。
|
|
495
|
+
|
|
496
|
+
---
|
|
497
|
+
|
|
498
|
+
## 为什么拆成三步
|
|
499
|
+
|
|
500
|
+
因为 **第 3 步不可能自动化**。把 145 张公式图转成 LaTeX,需要视觉模型逐张看懂
|
|
501
|
+
矩阵有几行、上下标在哪、括号层级如何。
|
|
502
|
+
|
|
503
|
+
如果做成一个大工具,Agent 就必须在一次调用里完成读图——它做不到。
|
|
504
|
+
拆开之后,Agent 可以在 extract 和 build 之间插入自己的读图逻辑,
|
|
505
|
+
结果通过一个 JSON 文件交接。
|
|
506
|
+
|
|
507
|
+
---
|
|
508
|
+
|
|
509
|
+
## 配置项
|
|
510
|
+
|
|
511
|
+
| 键 | 默认 | 说明 |
|
|
512
|
+
|---|---|---|
|
|
513
|
+
| `maxImageWidth` | 1600 | 渲染 PNG 的最大宽度 |
|
|
514
|
+
| `allowUvx` | true | 允许经 uvx 自动装 Python 依赖(需联网) |
|
|
515
|
+
| `python` | `''` | 指定解释器路径;留空自动探测 |
|
|
516
|
+
| `timeoutMs` | 600000 | 单次调用的超时 |
|
|
517
|
+
| `longTexChars` | 80 | 混排段里 LaTeX 源串超过这个长度就独立成块 |
|
|
518
|
+
| `t5MaxCellFormula` | 60 | 表格单元格内公式的字符上限(超过给 T5 警告) |
|
|
519
|
+
|
|
520
|
+
### 排版阈值怎么调
|
|
521
|
+
|
|
522
|
+
后两个是**排版取舍**,没有唯一正确的值——取决于文档给谁看:
|
|
523
|
+
|
|
524
|
+
| `longTexChars` | 效果 | 适合 |
|
|
525
|
+
|---|---|---|
|
|
526
|
+
| 小(如 40) | 大量公式独立成块,句子被切得更碎 | 移动端、窄栏、公式极长的文档 |
|
|
527
|
+
| 80(默认) | 长式独立、短式留句内 | 大多数情况 |
|
|
528
|
+
| 大(如 150–300) | 更多公式留在句内,句子更连贯 | 桌面 / A4 单栏、看重段落流畅的读者 |
|
|
529
|
+
|
|
530
|
+
同一篇 145 公式文档实测:40 → 行内 28、300 → 行内 68;**公式总数始终 145**,
|
|
531
|
+
改编排不会丢公式。
|
|
532
|
+
|
|
533
|
+
### 在哪改:两种传法
|
|
534
|
+
|
|
535
|
+
优先级 **单次调用 > profile 配置 > 默认值**。
|
|
536
|
+
|
|
537
|
+
**① 单次调用**——只对这一篇生效,最省事:
|
|
538
|
+
|
|
539
|
+
```js
|
|
540
|
+
await docx2md_build({ workDir: 'work/', latex: 'latex.json', out: '论文.md', longTexChars: 150 })
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
**② 改 profile 配置**——对所有转换生效。
|
|
544
|
+
|
|
545
|
+
> ⚠️ **不在插件管理界面。** 那个页面只显示插件信息与开关,没有参数表单。
|
|
546
|
+
> 参数写在 profile 的 `cordis.patch.yml` 里(`~/.dsh/profiles/desktop/cordis.patch.yml`),
|
|
547
|
+
> 按插件 `id` 加一条 config 覆盖:
|
|
548
|
+
|
|
549
|
+
```yaml
|
|
550
|
+
# ~/.dsh/profiles/desktop/cordis.patch.yml,追加到文件末尾
|
|
551
|
+
- id: docx2md
|
|
552
|
+
name: dsh-docx2md
|
|
553
|
+
config:
|
|
554
|
+
longTexChars: 150
|
|
555
|
+
t5MaxCellFormula: 200
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
`id` 必须是 `docx2md`(插件的 `cordis.patch.yml` 里声明的那一个),不是包名
|
|
559
|
+
`dsh-docx2md`。
|
|
560
|
+
|
|
561
|
+
profile 的 `package.json` 里若有 `"dsh": { "profile": { "patchReload": "live" } }`,
|
|
562
|
+
这条配置**会热加载**,不必重启 DSH。改完直接跑一次 `docx2md_build` 看返回值里的
|
|
563
|
+
`long_tex_chars` 就知道有没有生效。
|
|
564
|
+
|
|
565
|
+
**验证参数真的生效**:`docx2md_build` 的返回值里有 `long_tex_chars`,那是 Python
|
|
566
|
+
实际收到的值。不要只看自己传了什么——配置没写对时它不会报错,只是静默用默认值。
|
|
567
|
+
|
|
568
|
+
---
|
|
569
|
+
|
|
570
|
+
## 产出的 Markdown 遵循六条铁律
|
|
571
|
+
|
|
572
|
+
每条都来自真实踩坑:
|
|
573
|
+
|
|
574
|
+
1. `$$` 必须独占一行 —— 否则与后文某个 `$$` 错配,**从那里到文末全乱**,且不报错
|
|
575
|
+
2. 行内公式不能跨行 —— 不能含 `\begin{aligned}`、`\\`、`&`
|
|
576
|
+
3. 相邻公式之间要隔开 —— `$a$$b$` 会被解析成一个 `$$`
|
|
577
|
+
4. `\tag` 紧跟 `\end{aligned}`
|
|
578
|
+
5. 超长公式不要塞进表格单元格
|
|
579
|
+
6. 图片占位用 HTML 注释 —— `![]()` 空链接会显示破图
|
|
580
|
+
7. **行内公式与中文之间要有空格** —— `中文$公式$中文` 在不少渲染器里不被识别为
|
|
581
|
+
公式,用户看到的是 LaTeX 源码原样出现。`build.py` 会自动补,校验器用 **T10** 兜底。
|
|
582
|
+
|
|
583
|
+
`docx2md_build` 内置 `validate.py` 会逐条检查(T1–T12)。
|
|
584
|
+
|
|
585
|
+
---
|
|
586
|
+
|
|
587
|
+
## 测试
|
|
588
|
+
|
|
589
|
+
```bash
|
|
590
|
+
# 一次跑完全部功能与契约测试
|
|
591
|
+
./test/run-all.sh # 跳过需要真实 docx 的组
|
|
592
|
+
./test/run-all.sh 论文.docx # 含端到端
|
|
593
|
+
|
|
594
|
+
# 发布前:额外检查 tarball 内容
|
|
595
|
+
./test/run-all.sh --with-pack 论文.docx
|
|
596
|
+
|
|
597
|
+
# 单跑某项
|
|
598
|
+
node test/smoke.mjs /path/to/file.docx
|
|
599
|
+
node test/plugin.mjs /path/to/file.docx
|
|
600
|
+
node test/pack.mjs # 打包内容
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
所有脚本都输出 PASS/FAIL 判定表,退出码可用于 CI。
|
|
604
|
+
|
|
605
|
+
## 发布
|
|
606
|
+
|
|
607
|
+
上 GitHub 前先跑:
|
|
608
|
+
|
|
609
|
+
```bash
|
|
610
|
+
./test/run-all.sh --with-pack 论文.docx
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
它会额外验两组:`test/pack.mjs`(tarball 里有什么)和 `test/github-install.mjs`
|
|
614
|
+
(拿 tarball 真装一次、真跑一次、再卸载)。后者是关键——GitHub 安装最终产出的
|
|
615
|
+
就是 tarball,`file:` 本地目录安装走的却是另一条路(硬链接、整个目录都在),
|
|
616
|
+
两者不能互证。
|
|
617
|
+
|
|
618
|
+
### 打包内容
|
|
619
|
+
|
|
620
|
+
| 检查 | 目的 |
|
|
621
|
+
|---|---|
|
|
622
|
+
| 文件清单与预期完全一致 | 不多不少 |
|
|
623
|
+
| 无 `.pyc` / `__pycache__` / `test/` / 编辑器残留 | 干净 |
|
|
624
|
+
| 解包后能 import、能注册工具与技能 | 不依赖源码目录结构 |
|
|
625
|
+
| 解包后 `py/*.py` 能独立运行 | 同上 |
|
|
626
|
+
| `files` 写的是 `py/*.py` 而非 `py` | 见下 |
|
|
627
|
+
|
|
628
|
+
**`files` 为什么不能写裸目录。** 实测:`files: ["py"]` 会把
|
|
629
|
+
`py/__pycache__/*.pyc` 一起打进去(本机有 3 个 Python 版本的字节码);
|
|
630
|
+
而且 `.npmignore` 在 `files` 白名单模式下对已纳入的目录**不生效**——这一条与
|
|
631
|
+
直觉相反,是实测结论。改成 `py/*.py` 才真正排除,包体积从 121.9 kB 降到 107.1 kB。
|
|
632
|
+
|
|
633
|
+
当前包的 20 个文件:`LICENSE`、`README.md`、`cordis.patch.yml`、`package.json`、
|
|
634
|
+
`icon.svg`、`lib/index.js`、`lib/toolkit.js`、`py/*.py`(12 个)、`skills/docx2md/SKILL.md`。
|
|
635
|
+
|
|
636
|
+
### 从 tarball 安装这条路径
|
|
637
|
+
|
|
638
|
+
`test/github-install.mjs` 验证:打包 → 干净 profile 里 `pnpm add` → 检查
|
|
639
|
+
`node_modules` 内容 → 加载并注册 → 真跑一次 `inspect` → `pnpm remove` 后无残留。
|
|
640
|
+
|
|
641
|
+
三个只在实测中才会暴露的坑,都写进脚本注释了:
|
|
642
|
+
|
|
643
|
+
| 坑 | 现象 | 解法 |
|
|
644
|
+
|---|---|---|
|
|
645
|
+
| UNC 路径下建符号链接失败 | `os error 1(函数不正确)` | 测试环境放原生 Windows 路径 |
|
|
646
|
+
| Node 不能直接 spawn `.cmd` | `EINVAL` / `ENOENT` | 经 `cmd.exe /c` 调用 npm 与 pnpm |
|
|
647
|
+
| pnpm 拦截包内 skill | `Agent skills awaiting approval` | 功能不受影响;可 `pnpm approve` 复核 |
|
|
648
|
+
|
|
649
|
+
第三条是 pnpm 对第三方包声明 skill 的安全机制,不是插件缺陷,但用户会看到这条
|
|
650
|
+
提示,值得在 README 里说明。
|
|
651
|
+
|
|
652
|
+
### 安装时看到「Agent skills awaiting approval」怎么办
|
|
653
|
+
|
|
654
|
+
pnpm 12 起会对第三方包内声明的 skill 做一次人工确认,安装后打印:
|
|
655
|
+
|
|
656
|
+
```
|
|
657
|
+
Agent skills awaiting approval: dsh-docx2md@...
|
|
658
|
+
Run "pnpm approve" to review them.
|
|
659
|
+
```
|
|
660
|
+
|
|
661
|
+
这是 pnpm 的安全机制,**不是安装失败**,工具与技能都已经装好了(`test/github-install.mjs`
|
|
662
|
+
在出现这条提示的情况下仍实测通过:3 个工具 + 1 个技能注册正常,能处理真实文档)。
|
|
663
|
+
这条提示是 pnpm **每个依赖**(包括其他插件)一共汇总,不是本包专属。
|
|
664
|
+
|
|
665
|
+
想消掉它,在该 profile 目录执行一次:
|
|
666
|
+
|
|
667
|
+
```bash
|
|
668
|
+
cd ~/.dsh/profiles/desktop
|
|
669
|
+
pnpm approve
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
它会列出待批准项,按提示确认即可。若不想交互,也可在 `pnpm-workspace.yaml` 里
|
|
673
|
+
把本包加入 `onlyBuiltDependencies` / 相应的允许列表(具体字段随 pnpm 版本变化,
|
|
674
|
+
以 `pnpm approve` 的提示为准)。
|
|
675
|
+
|
|
676
|
+
### 测试的可移植性
|
|
677
|
+
|
|
678
|
+
`test/` 里的脚本原先写死了 `C:/Users/<某用户>/...`。现在全部通过
|
|
679
|
+
`test/lib/discover.sh` 解析:
|
|
680
|
+
|
|
681
|
+
| 需求 | 做法 |
|
|
682
|
+
|---|---|
|
|
683
|
+
| 一次性目录 | `mktemp -d`,落在系统临时目录 |
|
|
684
|
+
| Windows 路径 | `to_win_path` 把 `/mnt/c/...` 换算成 `C:\...` |
|
|
685
|
+
| 本机 DSH 位置 | 环境变量 → 自动发现 → **明确跳过** |
|
|
686
|
+
| Windows node / pnpm | `find_win_node` / `win_pnpm` |
|
|
687
|
+
|
|
688
|
+
**「明确跳过」是成功而不是失败。** 需要 Windows 环境的测试在 Linux/macOS 上会
|
|
689
|
+
打印 `skip ...` 并返回 0——否则别人 clone 后跑测试会莫名变红。
|
|
690
|
+
|
|
691
|
+
`sync-check.sh` 尤其如此:它检查本机 DSH 的三处代码是否同步,对使用者本来就
|
|
692
|
+
不适用,找不到目标时直接跳过。
|
|
693
|
+
|
|
694
|
+
### 仓库卫生
|
|
695
|
+
|
|
696
|
+
`.gitignore` 排除 `__pycache__/`、`*.pyc`、`*.tgz` 等。没有它时 `.pyc` 会被提交
|
|
697
|
+
进版本历史——之后再加 `.gitignore` 也只是停止追踪新文件,已有记录仍在。
|
|
698
|
+
|
|
699
|
+
### 开发用的 sync-check.sh 不进包
|
|
700
|
+
|
|
701
|
+
`test/sync-check.sh` 检查「源码 → 安装副本 → node_modules」三处是否一致,以及
|
|
702
|
+
宿主进程是否需要重启。它依赖本机的 pnpm `file:` 硬链接、宿主进程名和 `~/.dsh`
|
|
703
|
+
路径,**是开发环境专用的**,不出现在 tarball 里(`test/` 整目录都不进包)。
|
|
704
|
+
|
|
705
|
+
发布者用不到它;改代码后用它避免「改了没生效」的假验证:
|
|
706
|
+
|
|
707
|
+
```bash
|
|
708
|
+
./test/sync-check.sh # 退出码 0=可验 / 1=代码不同步 / 2=需重启
|
|
709
|
+
./test/sync-check.sh --fix # 自动同步源码到安装副本
|
|
710
|
+
```
|
|
711
|
+
|
|
712
|
+
### 中文编码:为什么单测要在伪造的 GBK 下跑
|
|
713
|
+
|
|
714
|
+
中文 Windows 上 Python 的 `sys.stdout.encoding` 默认是 **gbk**,非 ASCII 字符会被编成
|
|
715
|
+
GBK 字节;接收方按 UTF-8 解码就得到 `����`。这个故障是**静默**的,而且只在中文
|
|
716
|
+
Windows 上出现 —— 在 Linux/macOS 上永远不会复现(那里恒为 UTF-8)。
|
|
717
|
+
|
|
718
|
+
它的后果不只是「显示难看」:`docx2md_inspect` 的
|
|
719
|
+
`missing_preview_context[].paragraph_text` 是判断「缺图公式属于图注还是正文」的
|
|
720
|
+
唯一依据,乱码等于这个判断失效。
|
|
721
|
+
|
|
722
|
+
三层防御:
|
|
723
|
+
|
|
724
|
+
| 层 | 做法 | 位置 |
|
|
725
|
+
|---|---|---|
|
|
726
|
+
| 进程层 | 给子进程注入 `PYTHONUTF8=1` / `PYTHONIOENCODING=utf-8` | `lib/index.js` `pyEnv()` |
|
|
727
|
+
| 脚本层 | 每个脚本入口调 `force_utf8_stdio()`,不依赖环境变量 | `py/docxfile.py` |
|
|
728
|
+
| 接收层 | 严格 UTF-8 解码,非法字节直接报错而非替换成 `U+FFFD` | `lib/index.js` `decodeStrict()` |
|
|
729
|
+
|
|
730
|
+
回归测试用 `PYTHONIOENCODING=gbk` 在 Linux 上伪造同一环境(实测该设置会让
|
|
731
|
+
`python3` 把「位错」写成 GBK 字节,与 Windows 中文版一致),因此这条路径可在
|
|
732
|
+
任意平台复现:
|
|
733
|
+
|
|
734
|
+
```bash
|
|
735
|
+
node test/encoding.mjs # 用内联的微型 docx
|
|
736
|
+
node test/encoding.mjs 真实文档.docx # 再验一份中文文档
|
|
737
|
+
```
|
|
738
|
+
|
|
739
|
+
实测基线(145 公式的论文):
|
|
740
|
+
|
|
741
|
+
| 阶段 | 耗时 |
|
|
742
|
+
|---|---|
|
|
743
|
+
| inspect | ~0.4 s |
|
|
744
|
+
| extract | ~13 s(含 127 张图渲染) |
|
|
745
|
+
| build + validate | ~0.7 s |
|
|
746
|
+
|
|
747
|
+
---
|
|
748
|
+
|
|
749
|
+
## 性能实测
|
|
750
|
+
|
|
751
|
+
在 Windows 桌面版 DSH + Python 3.13 + uvx 兜底的组合下:
|
|
752
|
+
|
|
753
|
+
| 操作 | 输出到 Windows 本地 | 输出到 WSL 路径 |
|
|
754
|
+
|---|---|---|
|
|
755
|
+
| inspect(145 公式) | 5.2 s | — |
|
|
756
|
+
| extract + 渲染 127 张图 | **4.9 s** | 9.9 s |
|
|
757
|
+
| build + validate | 0.75 s | — |
|
|
758
|
+
|
|
759
|
+
**把工作目录放在 Windows 本地比放在 WSL 路径快一倍**(GDI+ 经 UNC 写图有固定开销)。
|
|
760
|
+
文档本身放 WSL 没问题,但 `outDir` 建议给个 Windows 本地路径。
|
|
761
|
+
|
|
762
|
+
---
|
|
763
|
+
|
|
764
|
+
## 已知限制
|
|
765
|
+
|
|
766
|
+
- **EMF 内嵌 PDF 文字层未实现**。「三种手段」一节里提到这条路径(★★★★,
|
|
767
|
+
变体 B 的解法),但它在原始实验里是手工做的一步,没有固化进本插件;`pypdf`
|
|
768
|
+
目前只是预留依赖,`py/` 下零引用。因此 15/145 的变体 B 公式只能靠渲染图读,
|
|
769
|
+
读不清时 `extract` 的 `summary.formulas_without_media` 会报出数量,不会假装成功。
|
|
770
|
+
详见 [docs/methodology.md](docs/methodology.md) 开头的说明。
|
|
771
|
+
|
|
772
|
+
- **只吃 `.docx`**。旧 `.doc` 请先用 Word / WPS 另存。非 zip、空 zip、缺
|
|
773
|
+
`word/document.xml`、加密文档都会给出可读错误并以退出码 3 结束。
|
|
774
|
+
**Python 脚本失败时不会留下空目录**(文档校验在建目录之前);但工具层
|
|
775
|
+
`docx2md_extract` 会先按 `outDir` 建目录再调用脚本,所以经工具调用时可能
|
|
776
|
+
留下一个空目录——里面不会有任何产物。
|
|
777
|
+
- **同一 outDir 会被清理**。换文档复用目录时,旧的 `paras.json`/`media/`/`png/`
|
|
778
|
+
以及**人工确认过的 `headings.json`** 都会被删除——段落号对另一份文档没有意义,
|
|
779
|
+
留着会让新文档的标题挂到错误的段落上。
|
|
780
|
+
- **无预览图的公式无法自动转换**(仅 OLE 族;OMML 从 XML 转出,不需要预览图)。
|
|
781
|
+
`inspect` 会报出数量,需要看原 Word 手工补。
|
|
782
|
+
- **OMML 转换覆盖常用结构,未知结构降级拼接**。分式、上下标、根式、定界符、
|
|
783
|
+
大运算符、函数、重音、横线、矩阵、方程组、underset/overset、纯文本样式、
|
|
784
|
+
希腊字母与常用符号(22 类逐一测试)。遇到不认识的节点时拼接其子节点内容——
|
|
785
|
+
宁可少一层装饰,绝不丢内容、绝不抛异常。降级发生时该公式仍会出现在产物里,
|
|
786
|
+
但可能缺外层结构;对关键公式建议抽查。
|
|
787
|
+
- **图片公式无法自动转换,但绝不静默丢**。截图/扫描进来的公式在 docx 里
|
|
788
|
+
就是一张位图,像素没有结构可解。工具做的是:按形状启发式(扁条状行内图:
|
|
789
|
+
宽高比≥2.5 且高≤120px)识别**候选**、生成 `⟦EQ:img:文件名⟧` 占位、把原件
|
|
790
|
+
抽进 `media/`;Agent 读图转写后以 `"img:文件名"` 为键回填 LaTeX。什么都不做
|
|
791
|
+
也不丢——build 会落成可见的图片引用(不是藏进注释),校验器 T11 保证引用
|
|
792
|
+
不断链。候选是启发式不是结论:行内小图标也可能被算进来,
|
|
793
|
+
`image_inventory.json` 带 ratio/尺寸/上下文供人逐个过目。
|
|
794
|
+
- **预览图必须按 ShapeID 配对,不能按顺序**。公式的预览图是带 `o:ole="t"` 的
|
|
795
|
+
`v:shape`,其 `id` 等于 `OLEObject` 的 `ShapeID`。早期实现按「同段落第 k 个
|
|
796
|
+
公式配第 k 张图」,在公式前面夹着插图的段落里会整体错位,把插图位图当成公式
|
|
797
|
+
预览图交给模型去读(实测 145 个公式里错 10 个,且不报错)。现在优先按
|
|
798
|
+
`ShapeID` 配对;只有文档不写 `ShapeID` 时才退回下标配对,并在
|
|
799
|
+
`summary.formulas_paired_by_ordinal` 与 `pairing_warning` 里明确报出来。
|
|
800
|
+
- **标题识别读的是 docx 的结构,不只是正则**。中文文档 `1.` / `A)` / `i)` / 无编号
|
|
801
|
+
「引言」混用是常态,更麻烦的是大量标题在段落文字里**根本没有编号**:Word 的自动
|
|
802
|
+
编号(`<w:numPr>`)是渲染时现算的,样式标题(`pStyle=heading N` / `outlineLvl`)
|
|
803
|
+
的文字跟正文一模一样。所以 `extract` 会额外落一个 `docstruct.json`(样式级别 /
|
|
804
|
+
合成出的自动编号 / 整段加粗),`build` 拿它补足纯正则认不出的标题。
|
|
805
|
+
实测同一份文档:纯正则 12 条 → 现在 27 条(人工整理稿 32 条,差额是参考稿把
|
|
806
|
+
附录与摘要也当节了)。
|
|
807
|
+
自动编号会被**补回标题文字**(`iii) 位错密度的演化方程`)——那段编号本来就该显示,
|
|
808
|
+
只是 docx 没把它存成字符;这是保真不是编造,值完全由 `numbering.xml` 推出。
|
|
809
|
+
只有加粗、无编号无样式的短标签(`攀移力`)默认也算标题,但要过两道判据:
|
|
810
|
+
不超过 15 字、且下一段是正文而不是公式(`位移场` 这类公式行标签因此被挡住)。
|
|
811
|
+
嫌吵用 `--no-bold-headings` 关掉。识别结果仍是初稿,`--suggest` 后再人工确认。
|
|
812
|
+
- **插图会进正文**。示意图、流程图按原位落成 ``(EMF/WMF
|
|
813
|
+
引用 `png/` 里渲染好的 PNG),不再静默丢弃。判定「哪张是公式预览图」靠结构而不是
|
|
814
|
+
宽高比:公式预览是带 `o:ole="t"` 且 `ShapeID` 命中 `OLEObject` 的 shape,逐张判
|
|
815
|
+
(`v:group` 里公式预览与拼图子图会共存,按容器整体判必然错)。仍**不要**用文字
|
|
816
|
+
描述重画示意图——实测 7 轮全失败。
|
|
817
|
+
- **按 OOXML 结构遍历,不假设「正文 = 一串段落」**。表格转 GFM 表格(行列对齐、
|
|
818
|
+
`gridSpan`/`vMerge` 合并列留空、绝不复制内容),列表按 `numPr` 转 `1.` / `-`,
|
|
819
|
+
行内加粗/斜体/下划线/删除线/上下标/超链接/脚注都还原成对应 Markdown。
|
|
820
|
+
`extract` 额外落 `blocks.json` 记录正文的真实块结构,`build` 按它的顺序输出,
|
|
821
|
+
段落与表格的交错顺序因此保住。没有 `blocks.json` 时退回纯段落模式(兼容旧产物)。
|
|
822
|
+
这是把 docx 当纯文本读的假设被推翻后的结果:早期版本对一份 4 行 2 列的表格
|
|
823
|
+
会转出「表头A / 表头B / 单元格1 / 单元格2」四行散段,行列关系消失且不报错。
|
|
824
|
+
- **强调标记必须能闭合**。`**应用公式 **`(闭定界符前有空白)在 CommonMark 里
|
|
825
|
+
不闭合,星号按字面输出。docx 里整段加粗的 run 常带尾随空格,所以 `build` 会把
|
|
826
|
+
首尾空白挪到标记外面;校验器 **T12** 成对扫描定界符,内侧有空白就报错。
|
|
827
|
+
- **只保证结构合规,不保证每个渲染器都认**。`docx2md_build` 的 PASS 意味着
|
|
828
|
+
六条铁律与 T9/T10 都过了,但不同 Markdown 预览器对数学语法的支持差异很大。
|
|
829
|
+
交付前**务必在目标渲染器里打开看一次**,尤其是长公式与矩阵。
|
|
830
|
+
|
|
831
|
+
| 现象 | 原因 | 查什么 |
|
|
832
|
+
|---|---|---|
|
|
833
|
+
| 公式显示成 LaTeX 源码 / 整块空白 | `$` 与中文紧贴,渲染器没识别成公式 | `validation.warnings` 里的 **T10** |
|
|
834
|
+
| 产物里出现 `![图片公式 …]` | 该公式在 Word 里就是张图,未转写(不丢机制在起作用) | `build` 返回的 `image_placeholders` |
|
|
835
|
+
| 图片公式引用是破图 | 引用目标文件不存在 | `validation.errors` 里的 **T11** |
|
|
836
|
+
| 显示成 `\begin{aligned}` 原文 | 块级环境写进了行内公式 | **T2** |
|
|
837
|
+
| 只有长的、矩阵的坏 | 同上,或该渲染器不支持某些命令 | T2 + 该渲染器的 KaTeX 版本 |
|
|
838
|
+
|
|
839
|
+
> 实测教训:`katex.renderToString` 能渲染,**不等于** Markdown 解析器会把
|
|
840
|
+
> 它识别成公式。这是两层独立判定,必须分开测。
|
|
841
|
+
|
|
842
|
+
---
|
|
843
|
+
|
|
844
|
+
## 目录结构
|
|
845
|
+
|
|
846
|
+
```
|
|
847
|
+
dsh-docx2md/
|
|
848
|
+
├── lib/
|
|
849
|
+
│ ├── index.js 插件主体:注册 3 个工具 + 1 个技能
|
|
850
|
+
│ └── toolkit.js defineTool 的零依赖实现
|
|
851
|
+
├── py/
|
|
852
|
+
│ ├── docxfile.py .docx 打开与结构校验(坏输入转可读错误)
|
|
853
|
+
│ ├── docx_scan.py 只读扫描(OLE / OMML / 图片候选三类分开计数)
|
|
854
|
+
│ ├── rich.py 按 OOXML 块级结构读取(表格/行内格式/链接/脚注/修订)
|
|
855
|
+
│ ├── docstyle.py 段落结构信号(样式级别 / 自动编号 / 整段加粗)→ 判标题
|
|
856
|
+
│ ├── imgsize.py 图片自身像素尺寸(判「扁条公式还是示意图」)
|
|
857
|
+
│ ├── extract.py 正文块 + 公式 + 预览图 + 插图(OMML 在此转出 LaTeX)
|
|
858
|
+
│ ├── omml.py OMML → LaTeX 确定性转换器
|
|
859
|
+
│ ├── imgformula.py 图片公式候选检测(形状启发式 + OLE 预览排除 + 画布内公式识别)
|
|
860
|
+
│ ├── render.py 跨平台 WMF/EMF → PNG
|
|
861
|
+
│ ├── build.py 块结构 + LaTeX 表 → Markdown(表格/列表/行内格式)
|
|
862
|
+
│ └── validate.py 六条铁律 + T9–T14 校验
|
|
863
|
+
├── skills/docx2md/
|
|
864
|
+
│ └── SKILL.md 技能说明(给 Agent 读)
|
|
865
|
+
├── docs/
|
|
866
|
+
│ └── methodology.md 完整方法论(转换实验总结)
|
|
867
|
+
├── test/ 24 组测试(22 日常 + 2 发布前;不进发布包)
|
|
868
|
+
├── cordis.patch.yml 插件注册
|
|
869
|
+
└── CHANGELOG.md
|
|
870
|
+
```
|
|
871
|
+
|
|
872
|
+
`lib/` 只用 Node 内建模块。这不是洁癖——第三方插件装在 profile 的
|
|
873
|
+
`node_modules` 下,而 DSH 自身的包在 `app.asar` 里,标准 Node 解析不到
|
|
874
|
+
`@deepseek-ai/*`。详见 `lib/toolkit.js` 顶部注释。
|
|
875
|
+
|
|
876
|
+
---
|
|
877
|
+
|
|
878
|
+
## License
|
|
879
|
+
|
|
880
|
+
MIT
|