본문으로 건너뛰기
S

dsh-docx2md

stallenlu/dsh-docx2md

Converts formula-bearing Word documents into Markdown with LaTeX, handling OLE equation objects, native Word OMML equations and image-based formulas; keeps equation numbers and structure. Needs Python 3.9+ (or uv); OLE and image formulas are transcribed by a vision-capable model from rendered images, and rendering needs Windows or LibreOffice.

설치

dsh plugin --profile web add github:stallenlu/dsh-docx2md

README

dsh-docx2md

把含公式的 Word 文档转成带 LaTeX 的 Markdown —— 跨平台,保留公式编号与结构。 OLE 公式(Equation Editor / MathType)、Word 原生公式(OMML,新版 Word 的 默认格式)、图片形式的公式(截图/扫描)三种载体都处理。

A DeepSeek Harness plugin that converts Word documents with embedded equations — legacy OLE (MTEF), native OMML, and image-embedded formulas — into Markdown with LaTeX, preserving numbering and document structure.


三种载体,三条路

OLE 公式OMML(Word 原生)图片公式
谁在用Equation Editor / MathType;WPS 公式的默认载体Word 2007+ 插入公式的默认格式;2016 后手敲 LaTeX 存下来也是它截图粘贴、扫描页、从 PDF 拷贝
存在哪私有二进制(MTEF)包在 oleObjectN.bin结构化 XML(m:oMath)一张位图(w:drawing / v:pict)
怎么转渲染成 PNG → 视觉模型逐张读图转写extract 阶段确定性转成 LaTeX,不读图无法自动转换(像素无结构可解);落成占位符,读 media/ 原件转写,键 "img:文件名"
质量取决于读图;有校验器与配对告警兜底22 类结构逐一测试(分式/上下标/根式/矩阵/大运算符/重音/…)取决于读图;候选按形状启发式(扁条行内图),清单带证据供人过目
需要人工是(第 3 步)否(不满意可覆盖,见下)是(第 3.5 步)
不处理会怎样报 T9 未解析——不丢:build 落成可见图片引用(![图片公式 …]),T11 查断链

纯 OMML 文档全程无需读图:docx2md_build 用空的 latex 表也能产出完整 Markdown(OMML 自动预填)。混排文档里只有 OLE 与图片那部分需要视觉转写。

为什么 OLE 需要看图

普通的 pandoc doc.docx -o doc.md 遇到 Word 的 OLE 公式基本无能为力:那是 WPS / Word 的私有二进制格式(MTEF),解出来必然失真。

本插件对 OLE 公式的方案只有一句话:

以渲染出的位图为准,不以解析出的字符为准。

实测同一份 145 个公式的文档,直接解 MTEF:

公式看图应有的字符量MTEF 解出
#27151(5 行矩阵式)βP — 2 个字符
#30150(5 行)u=dr — 4 个字符
#65 个等式链α=-∇×βP

它不会报错,只是安静地给你一个残缺结果。 这是最危险的一类失败。 所以本插件的设计原则是:渲染失败就明确失败,绝不降级到字符解码。


安装

DSH 的 profile 用 pnpm 管理,node_modules 是 pnpm 管的(nodeLinker: hoisted)。 不要手工往 node_modules 里拷目录——下次 pnpm install 会把没有写进 lockfile 的目录删掉。

本包已发布到 npm(dsh-docx2md@0.6.5), 日常使用直接装正式版本,不依赖本地源码:

cd ~/.dsh/profiles/desktop
pnpm add dsh-docx2md@0.6.5          # 正式 npm 包(推荐,会写进 pnpm-lock.yaml)

# 或 DSH 安装器一条命令(等价于上面的 pnpm add)
dsh plugin --profile desktop add dsh-docx2md@0.6.5

# 或装版本固定的 GitHub Release tarball(与 npm 包字节一致,不需要 npm)
dsh plugin --profile desktop add https://github.com/stallenlu/dsh-docx2md/releases/download/v0.6.5/dsh-docx2md-0.6.5.tgz

升级时把 @0.6.5 换成新版本号即可。

声明的两处

package.json 里有两件事都要做:

{
  "dependencies": { "dsh-docx2md": "0.6.5" },
  "dsh": { "profile": { "bundles": [ /* ... */, "dsh-docx2md" ] } }
}

只需加进 bundles。本包的 package.json 已声明 dsh.bundle.patch: ./cordis.patch.yml,DSH 会自动读取它并把插件插入。 不要再往 profile 的 cordis.patch.yml 里手写 insert —— 那会导致同一插件被 注册两次。

dependencies 里写的是 npm 版本号(pnpm add dsh-docx2md@0.6.5 会自动写入这一行和 pnpm-lock.yaml);不要再写成 file: 路径或 github: 地址。

让配置改完立即生效

profile 的 package.json 里加上:

"dsh": { "profile": { "patchReload": "live" } }

没有这一项时,bundles 的变更要重启 DSH 进程才生效。 注意:技能列表走另一套机制(dsh-skill-filesystem 用 chokidar 监听文件), 所以可能出现"技能已出现、工具还没出现"的中间状态——这不是 bug,重启即可。

本地开发(从源码安装)

只有改本插件代码做开发时才需要这一节;日常使用请直接装上面的 npm 正式包。

# 1. 把包放到 profile 外部(file: 依赖不能指向 node_modules 内部,会成自环)
cp -r dsh-docx2md ~/.dsh/packages/dsh-docx2md

# 2. 在 profile 里用 pnpm 安装(会写进 pnpm-lock.yaml)
cd ~/.dsh/profiles/desktop
pnpm add file:~/../packages/dsh-docx2md

Windows 上路径要写成 pnpm add file:C:/Users/<你>/.dsh/packages/dsh-docx2md。 也可以直接从 Git 仓库装开发中的版本:

cd ~/.dsh/profiles/desktop
pnpm add github:<你的账号>/dsh-docx2md

再把包名加进 bundles。装好之后改 ~/.dsh/packages/dsh-docx2md 里的文件即可—— pnpm 建的是硬链接,改动会立即反映到 node_modules(若用编辑器原子写入导致 硬链接断开,重跑一次 pnpm install 即可)。file: / github: 这类依赖只用于开发, 正式环境请用 dsh-docx2md@0.6.5。


更新

从 npm 安装时,升级就是换版本号(lock 里记的是版本号 + integrity):

cd ~/.dsh/profiles/desktop
pnpm add dsh-docx2md@<新版本>        # 或 pnpm update dsh-docx2md

改完重启 DSH,跑一次 docx2md_inspect 确认行为符合预期。

下面「更新 / sync-check」几段只适用于本地开发(file: 依赖);从 npm 安装无需这些步骤。

file: 依赖在 lock 里记为 type: directory(不是版本号),所以 pnpm 不按版本判断 是否需要更新——它只是指向那个目录。因此“更新”要分三种情况:

情况需要做什么实测
只改代码,版本号不变pnpm install + 重启 DSHnode_modules 立即同步(硬链接)
改了版本号同上package.json 随硬链接自动同步
文件被原子写入(编辑器保存、git checkout、cp 覆盖)必须 pnpm install硬链接断开,node_modules 保留旧代码

第三条是最容易踩的坑:inode 变了,硬链接断了,node_modules 里还是旧代码。 没有报错,工具照常运行,只是行为没变——你在源目录看到的是新代码,实际加载的不是。

标准更新流程:

# 1. 改代码(改 ~/.dsh/packages/dsh-docx2md 或你的源码目录后同步过去)
# 2. 修复可能断开的硬链接(1-2 秒,不要省)
cd ~/.dsh/profiles/desktop && pnpm install
# 3. 重启 DSH
# 4. 验证:跑一次 docx2md_inspect 确认行为符合预期

test/update.sh 会把这四种情况都在一次性环境里复现一遍。

改完先跑 sync-check.sh

上面三步(同步 → 修链接 → 重启)只要漏一步,跑出来的“验证通过”就是假的。 开发时改的是源码目录,DSH 加载的是 node_modules 里那份,中间隔着复制和硬链接 两道;任何一道断了都不报错,只是行为不变。

./test/sync-check.sh          # 检查
./test/sync-check.sh --fix    # 不一致时自动同步源码 → 安装副本

它检查四件事,并用退出码区分严重程度:

退出码含义该怎么办
0三处一致,且进程加载的是最新代码可以跑真机验证
1代码不同步(副本落后 / 硬链接断裂)--fix,必要时 pnpm install
2三处代码已一致,但 DSH 进程是旧版本重启 DSH,之后才有意义

分开 1 和 2 是有意的:退出码 2 表示「代码没问题,只差重启」,CI 不该因此报失败; 退出码 1 才是真的代码不一致。

脚本以 package.json 的 files 字段为准来列清单(test/ 不随包安装,不参与比对)。 它也会读宿主进程启动时间,与最新文件修改时间对比,因此能直接告诉你「要不要重启」。


卸载

cd ~/.dsh/profiles/desktop
pnpm remove dsh-docx2md

然后从 package.json 的 dsh.profile.bundles 里删掉 "dsh-docx2md",重启 DSH。 若曾用 file: 本地开发安装,~/.dsh/packages/dsh-docx2md 源码副本可留着下次用,也可以删掉;从 npm 安装则没有这个目录。

验证是否干净:node_modules/dsh-docx2md 不存在、pnpm-lock.yaml 无记录、 bundles 无条目。本仓库 test/lifecycle.sh 就是跑这套闭环的。


运行环境

依赖用途必需
Python 3.9+跑 py/ 下的脚本✅
olefile解 OLE 容器、取 Equation Native 流自动经 uvx 安装
pypdf预留,当前未使用(见「已知限制」)自动经 uvx 安装
uv(可选)免手动装 Python 依赖推荐

渲染引擎(用于把 WMF/EMF 公式图转 PNG),按顺序自动探测:

平台引擎说明
Windows(桌面 App)GDI+ (System.Drawing)原生支持;实测 127 张图 4.9 s
WSL调宿主 Windows 的 GDI+自动转换路径为 UNC
Linux / macOSLibreOffice (soffice)需自行安装
兜底Pillow + libwmf少见,有就用

全都没有时,docx2md_inspect 会明确告诉你,docx2md_extract 会保留原始 矢量图、把 render.ok 置为 false——不会假装成功。

画布渲染(用于把流程图 v:group 整块转成 PNG),需要无头浏览器:

平台引擎说明
Windows / WSLEdge 或 Chrome 的 --headless自动探测 msedge / chrome / 常见安装路径

画布有两种表示,都要整块渲染:

docx 里的写法是什么为什么不能逐张抽
VML v:group流程图(框/菱形/箭头/标签)文字层表达不了「这些框怎么连」
DrawingML wpc图版(3×2 网格等)每张图带 a:off/a:ext 绝对坐标,逐张抽会把网格压成竖排
VML v:group(纯多图)同上,只是老写法同上

同一张图版在 mc:AlternateContent 里有 Choice(wpc,带坐标)和 Fallback(v:group,只有顺序)两份,只取 Choice:那份才有位置信息, 且两者是同一份内容,都读会重复出图。

没有可用浏览器时,画布不会静默消失:summary.json 里 canvases_rendered 会小于 canvases,并给出 canvas_warning 指明哪一块、 为什么失败。

「渲染成功」的判据不是「PNG 存在且非空」——浏览器打开坏 URL 时会把 一张「未找到文件」错误页截成 PNG,尺寸、字节数、退出码全都正常。 所以截图时会同时 --dump-dom,DOM 里必须能找到 SVG 根上的 data-docx2md="canvas" 标记,否则判失败。


用法

在 Agent 里

直接说:

把 论文.docx 转成 Markdown,公式要保留。

Agent 会加载 docx2md 技能,按 inspect → extract → 读图 → build 的顺序执行。

三个工具

docx2md_inspect(path)

只读扫描。返回段落数、公式数(分 formulas=OLE 与 formulas_omml=Word 原生 三类:OLE / OMML / 图片公式候选)、变体分布(A 可解 / B 必须看图)、预览图格式分布、本机渲染引擎。

它只输出事实,不给结论。 例如缺预览图的公式,inspect 会给出 missing_preview_context(所在段落、段落文字、前后段),但不会替你判断 「这是图注标注还是正文公式」——那需要看全文,属于调用方的判断。

docx2md_extract(path, outDir?, render?)

产出:

  • paras.json —— 正文段落,公式位置为 ⟦EQ:oleObject12.bin⟧(OLE)、 ⟦EQ:omml3⟧(Word 原生)或 ⟦EQ:img:文件名⟧(图片公式候选)
  • eq_index.json —— 每个公式的 stream / 预览图 / 字节数 / 变体 / pairing; OMML 条目直接带 latex 字段(从 XML 结构确定性转出,无需读图)
  • media/ —— 原始 EMF/WMF 与图片公式的位图原件
  • png/ —— 可读位图(读这个目录)
  • image_inventory.json —— 正文全部行内图片的形状证据(ratio / 尺寸 / 上下文段落),图片公式候选的判定依据;示意图也在这里,供人核对
  • textbox_labels.json —— 流程图文本框里的标签文字("开始"、"结束"、 "输出应力、应变"…)。它们不是正文段落(混进正文会让段落数与原文对不上), 但也不能丢——那是文档里真实存在的文字,单独留痕供人核对
  • media/canvasN.png —— 流程图/示意图画布(v:group)整块渲染的图。 画布里的框、菱形、箭头、标签与公式都在这一张图里;画布内的公式因此 不再单独占位(eq_index.json 里标 in_canvas: true),否则同一批 公式会出现两遍
docx2md_build(workDir, latex, out, suggestHeadings?, useSuggestedHeadings?,
              expectFormulas?, longTexChars?, t5MaxCellFormula?, cleanup?)

把 {"oleObject1.bin": "\\alpha", ...} 合成 Markdown 并校验。 expectFormulas 会核对公式总数,能抓住漏转。OMML 公式无需出现在 latex 表里 (自动预填;给了同名键则以你给的为准)。图片公式不转写也不丢——落成 ![图片公式 …](media/…) 可见引用;转写时键用 "img:文件名"。

产物落在哪、会不会留垃圾

插件不创建系统临时目录(不用 mkdtemp / tempfile / TMPDIR),所有中间产物 都在你给的 outDir 里;不给就落在 docx 同级的 <名字>_docx2md/。

一次转换产生 6 个条目(145 公式的论文实测 4.3 MB):

条目内容大小
paras.json正文段落,公式位置为 ⟦EQ:oleObjectN.bin⟧~20 KB
eq_index.json公式索引(stream / 预览图 / MTEF 变体)~24 KB
image_inventory.json行内图片的形状证据与上下文(图片公式候选清单)~1 KB
textbox_labels.json流程图文本框里的标签文字(非正文段落,单独留痕)~0.2 KB
media/原始矢量预览图(wmf/emf)+ 文档自带位图~1.3 MB
png/渲染后的可读位图,Agent 靠它转写 LaTeX~3.0 MB
summary.json本次提取的统计(含 canvases / canvases_rendered)~0.3 KB

不会残留的东西:渲染用的 _render_report.json 读完即删;坏输入不会留下空目录。 png/ 里出现的 .jpeg 不是残留,而是原文档自带的位图预览(inspect 的 media_formats 会报出 jpeg 数量)。

清理:插件没有 --clean 参数,也不需要——outDir 是唯一写入点,rm -rf 该目录即可。 同一 outDir 复用时旧产物会被自动清掉,不会混入上一份文档的图。

清理:默认什么都不删(png/ 是你事后核对公式的凭据)。想要自动清理就传 cleanup: true 给 docx2md_build:

await docx2md_build({
  workDir: 'work/', latex: 'work/latex.json', out: '论文.md',
  expectFormulas: 145, cleanup: true,
})

cleanup 只在构建完成且校验 PASS 时才删 media/ 与 png/,保留 paras.json、eq_index.json、summary.json(复现用)。校验没通过时不删—— 那时 png/ 正是排查「哪张图读错了」的唯一依据。返回值里的 cleanup 字段会 说明删了什么、或为什么没删。

也可以不给 cleanup,直接 rm -rf 该目录,效果一样。

校验器查什么

docx2md_build 内部会调 validate.py,除六条 Markdown 铁律外还查:

检查抓住什么
T0空文件
T1–T3$$ 未独占一行、行内公式含块级环境、相邻公式块紧贴
T7$$ 奇数个(未闭合)
T9公式没解析出来(残留 ⟦EQ:...⟧,或 build 写的 <!-- 未解析公式 -->)
T8expectFormulas 给了时,公式总数不符
T10行内公式与中文紧贴(部分渲染器不认定界符)
T11图片公式的引用断链(破图 = 公式又消失了)
T12强调标记不闭合(**应用公式 **,闭定界符前有空白,星号按字面输出)
T13多行公式被并成一行(预览图行高远超单行基线、转写里还留着 \quad 分行痕迹)。build 会自动拆回 aligned,这里是复查网;需要 --media + --latex,缺证据时 docx2md_build 用 rowCheck 明说跳过
T14转写漏了内容(预览图很宽、转写却几乎没字:图宽 ÷ 字形数 > 全体中位 6 倍)。中文与公式混排的长条最容易整段没转进来

T9 值得单独说:latex 表里缺的公式,build 会写成 HTML 注释。这种文档六条铁律 全过、注释渲染后也看不见,但正文里确实少了公式。早期版本只查铁律,会给出 PASS——现在判 FAIL。

提取阶段的自检:把「静默」变成「显式」

上面那些校验都在 build 阶段。更早的一道闸在 extract:

eq_index.json 里每个公式,正文里都必须有对应的 ⟦EQ:…⟧ 占位符; 对不上就直接报错退出(退出码 4),不带缺口往下走。

为什么需要它:Word 画流程图时,公式会嵌在同一个 w:pict 的 v:group 里。 那个 pict 既是「装着公式的画布」,又长得像「公式的预览图」(里面同样有 o:ole="t" 的 v:shape)。实现若按「见 o:ole="t" 就算预览图」判断,整棵 pict 被当成一张预览图跳过,里面的 o:OLEObject 一并被吞掉——

正文照常输出,不报错、不告警,只是公式消失了。

实测在真实文档(145 个公式)里,3 个 pict 各装着 6–8 个公式,共 20 个 公式无声消失。校验器直到 build 阶段才对不上数(T8 FAIL),而那时原因已经 无从查证。

所以除了修代码,还加了这道自检:真出问题时,extract 立刻停、并把少了哪几个 stream 打印出来,而不是把缺口一路带到 Markdown 里。

退出码含义
0正常
2参数用错(见 --help)
3输入不是可读的 .docx(用户的问题)
4提取自检失败:索引里有公式、正文里没有占位符(工具的问题)

3 和 4 分开是有意的:前者去查文档,后者去查「哪种容器把公式吞了」。

「像正文、其实不是」的三样东西

Word 文档里有三样东西长得像正文段落,其实不是。搞混它们,症状都是 「内容莫名少了一段/少了一个公式」,而且都不报错:

东西在 XML 里归属去了哪
流程图文本框里的标签文字w:pict → v:shape → v:textbox → w:txbxContent → w:p图的标注,不是正文textbox_labels.json
流程图文本框里的公式同上,但框里还有 o:OLEObject图的内容,必须保留归最外层段落,占位符留在正文里
图片公式的预览位图v:shape o:ole="t" + v:imagedata公式的渲染图media/ + png/,供读图转写
流程图画布本身v:group(内含框/菱形/箭头/标签 + OLE)一整块结构,文字层表达不了media/canvasN.png,正文里 ![图](…);画布内公式标 in_canvas

三条规则:

  1. 段落只数最外层的。文本框里的段落自己也是 w:p,逐个遍历会把它顶出来 ——于是同一段内容被记两遍。inspect 报的 paragraphs 只数正文段落, 文本框里的另计在 paragraphs_in_textbox(口径与 extract 一致)。
  2. 公式归最外层的段落。按最近的 w:p 归属,公式会跟着嵌套段落一起被 排除出正文——公式就这么丢了。归最外层,位置不丢,框里的公式也照样占位。
  3. 文字与公式各归其位。框里的文字不是正文段落(会污染段落序列), 框里的公式是正文内容(丢了就是丢内容)。一个走 textbox_labels.json, 一个留在正文占位符里。

实测:一份 145 公式的真实文档,流程图里住了 20 个公式和 6 条标签。 修之前 20 个公式被静默吞掉、6 条标签变成 6 个莫名其妙的正文段落; 修之后 145 个公式一个不少、段落数从 308 降到 293(差的 15 段正是 流程图里的标签段落)。

同一条规则,两个工具必须用同一把尺子。 inspect 的 missing_preview_context 报「这一段的文字是什么」,供人判断缺图的公式该怎么 处理。它的取字方式若与 extract 不同,同一个段落索引就会各说各话——实测 inspect 说「段落 257 的文字是『VDDD3D程序结构图结束 t=t + dt输出应力、应变、 位错密度等』」,而 extract 的 paras.json 里同一段只有公式占位符。排查时 很容易被当成「文档本来就这样」。

现在两边都只取属于本段落的 w:t,文本框里的文字不算本段的文字。

过期标题表会吞掉公式(build 阶段的一道闸)

headings.json 是按段落序号索引的人工确认表。段落序号会随提取口径变化, 一旦变了,表就指错了段落——而指错的段落若恰好是一个独占的公式段, 那条公式会被当成标题直接输出,公式凭空消失。

实测踩过:段落数从 308 变 293 后,旧表把「段落 271」标成 4 级标题,而那正是 唯一一条 oleObject139 公式所在的段。build 不报错,validate 到 T8 才报 144 ≠ 145,而真正的错因(标题表过期)在报告里完全看不出来。

所以 build.py 现在会检查:标题指向的段落里若含公式占位符,直接报错退出 (退出码 3),并提示重新 --suggest 生成。宁可停下来问一句,也不要让 公式换一种方式消失。

完整流程示例

// 1. 看清有什么
await docx2md_inspect({ path: '论文.docx' })

// 2. 提取 + 渲染
await docx2md_extract({ path: '论文.docx', outDir: 'work/' })

// 3. ← 这里由 Agent 逐张读 work/png/*.png,转写成 latex.json
//    (这一步无法自动化:需要视觉理解公式结构)

// 4. 先看标题初稿
await docx2md_build({
  workDir: 'work/', latex: 'work/latex.json',
  out: '论文.md', suggestHeadings: true,
})
// 人工修订 work/headings_suggested.json → 改名 headings.json

// 5. 正式构建 + 校验
await docx2md_build({
  workDir: 'work/', latex: 'work/latex.json',
  out: '论文.md', expectFormulas: 145,
})

路径怎么写都行

三个工具都接受你从当前环境出发自然写出的路径,不需要考虑宿主是 Windows 还是 WSL:

你写的在 Windows 宿主上落到说明
论文.docx会话工作目录下相对路径
/home/sidady/proj/论文.docxWSL 视图的同一位置Linux 绝对路径
/tmp/workWSL 的 /tmp/work不是 C:\tmp
/mnt/c/workC:\workWSL 挂载点会自动换算
C:\work\cdot.docxC:\work盘符路径原样
\\wsl.localhost\Ubuntu\tmp原样UNC 路径原样

返回的路径会自动折回你更习惯的写法:WSL 里的位置返回成 /tmp/work,Windows 位置返回成 C:\work,可以直接粘进 shell 或 read 工具。

早期版本在这里有个坑:path.isAbsolute('/tmp/x') 在 Windows 上返回 true,插件就把 字符串原样交给了 Python,而 Python 按盘符根解析成了 C:\tmp\x。现在一律先由 Node 按会话工作目录的卷归一化,不再透传未归一化的字符串。回归测试见 test/paths.mjs。


为什么拆成三步

因为 第 3 步不可能自动化。把 145 张公式图转成 LaTeX,需要视觉模型逐张看懂 矩阵有几行、上下标在哪、括号层级如何。

如果做成一个大工具,Agent 就必须在一次调用里完成读图——它做不到。 拆开之后,Agent 可以在 extract 和 build 之间插入自己的读图逻辑, 结果通过一个 JSON 文件交接。


配置项

键默认说明
maxImageWidth1600渲染 PNG 的最大宽度
allowUvxtrue允许经 uvx 自动装 Python 依赖(需联网)
python''指定解释器路径;留空自动探测
timeoutMs600000单次调用的超时
longTexChars80混排段里 LaTeX 源串超过这个长度就独立成块
t5MaxCellFormula60表格单元格内公式的字符上限(超过给 T5 警告)

排版阈值怎么调

后两个是排版取舍,没有唯一正确的值——取决于文档给谁看:

longTexChars效果适合
小(如 40)大量公式独立成块,句子被切得更碎移动端、窄栏、公式极长的文档
80(默认)长式独立、短式留句内大多数情况
大(如 150–300)更多公式留在句内,句子更连贯桌面 / A4 单栏、看重段落流畅的读者

同一篇 145 公式文档实测:40 → 行内 28、300 → 行内 68;公式总数始终 145, 改编排不会丢公式。

在哪改:两种传法

优先级 单次调用 > profile 配置 > 默认值。

① 单次调用——只对这一篇生效,最省事:

await docx2md_build({ workDir: 'work/', latex: 'latex.json', out: '论文.md', longTexChars: 150 })

② 改 profile 配置——对所有转换生效。

⚠️ 不在插件管理界面。 那个页面只显示插件信息与开关,没有参数表单。 参数写在 profile 的 cordis.patch.yml 里(~/.dsh/profiles/desktop/cordis.patch.yml), 按插件 id 加一条 config 覆盖:

# ~/.dsh/profiles/desktop/cordis.patch.yml,追加到文件末尾
- id: docx2md
  name: dsh-docx2md
  config:
    longTexChars: 150
    t5MaxCellFormula: 200

id 必须是 docx2md(插件的 cordis.patch.yml 里声明的那一个),不是包名 dsh-docx2md。

profile 的 package.json 里若有 "dsh": { "profile": { "patchReload": "live" } }, 这条配置会热加载,不必重启 DSH。改完直接跑一次 docx2md_build 看返回值里的 long_tex_chars 就知道有没有生效。

验证参数真的生效:docx2md_build 的返回值里有 long_tex_chars,那是 Python 实际收到的值。不要只看自己传了什么——配置没写对时它不会报错,只是静默用默认值。


产出的 Markdown 遵循六条铁律

每条都来自真实踩坑:

  1. $$ 必须独占一行 —— 否则与后文某个 $$ 错配,从那里到文末全乱,且不报错
  2. 行内公式不能跨行 —— 不能含 \begin{aligned}、\\、&
  3. 相邻公式之间要隔开 —— $a$$b$ 会被解析成一个 $$
  4. \tag 紧跟 \end{aligned}
  5. 超长公式不要塞进表格单元格
  6. 图片占位用 HTML 注释 —— ![]() 空链接会显示破图 补充一条(校验器用 T10 兜底):行内公式与中文之间要有空格 —— 中文$公式$中文 在不少渲染器里不被识别为公式,用户看到的是 LaTeX 源码原样 出现;build.py 会自动补空格。

docx2md_build 内置 validate.py 会逐条检查(T1–T14)。


测试

# 一次跑完全部功能与契约测试
./test/run-all.sh                     # 跳过需要真实 docx 的组
./test/run-all.sh 论文.docx            # 含端到端

# 发布前:额外检查 tarball 内容
./test/run-all.sh --with-pack 论文.docx

# 单跑某项
node test/smoke.mjs   /path/to/file.docx
node test/plugin.mjs  /path/to/file.docx
node test/pack.mjs                    # 打包内容

所有脚本都输出 PASS/FAIL 判定表,退出码可用于 CI。

发布

上 GitHub 前先跑:

./test/run-all.sh --with-pack 论文.docx

它会额外验两组:test/pack.mjs(tarball 里有什么)和 test/github-install.mjs (拿 tarball 真装一次、真跑一次、再卸载)。后者是关键——GitHub 安装最终产出的 就是 tarball,file: 本地目录安装走的却是另一条路(硬链接、整个目录都在), 两者不能互证。

CI 与发布自动化

仓库带两条 GitHub Actions:

工作流触发做什么
.github/workflows/ci.ymlpush 到 main / PR / 手动语法门禁、工具与技能契约、全部不依赖渲染引擎的 Python e2e、工具层端到端、test/pack.mjs 打包门禁
.github/workflows/publish.ymlGitHub Release 发布 / 手动跑 test/pack.mjs 后 npm publish --provenance --access public

CI 刻意不跑 test/run-all.sh 的全套:7j 画布渲染需要无头 Edge 或 LibreOffice(GitHub-hosted runner 没有),11/15/16 又依赖本机 DSH profile;这些在干净 CI 里要么失败要么无意义。CI 只跑环境无关、本机实测全绿的 那一组,画布渲染留给本地有人值守时验证。

package.json 的 prepack 指向 node test/prepack.mjs:每次 npm pack / npm publish 自动做一遍静态门禁(元数据齐备、入口与资源文件齐全、 files 白名单必须写 py/*.py 而不是裸 py——后者会把 py/__pycache__/*.pyc 打进 tarball)。本地 / CI 跑过 Python 测试后 py/__pycache__ 常驻是正常现象, prepack 只提示、不误判为失败。prepack 的所有输出走 stderr:npm 会把它的 stdout 转发进 npm pack --json 的 stdout,那样会污染 JSON。 prepack 里绝不能再调 npm pack——npm pack --dry-run 也会触发 prepack, 那样会无限递归。真正的 tarball 清单核对放在 test/pack.mjs(由 CI 单独跑)。

provenance 由 publishConfig.provenance: true + 发布工作流的 --provenance + permissions: id-token: write 三重保证:npm 用 GitHub OIDC 把包签进 Sigstore 公开透明日志,npmjs 页面会显示「Built and signed on GitHub Actions」。需要仓库 secret NPM_TOKEN(npm 自动化/粒度令牌,对本包有发布权限); 发布必须在 GitHub-hosted runner 上跑(OIDC 令牌只有云托管 runner 才发)。详见 npm provenance 文档。

打包内容

检查目的
文件清单与预期完全一致不多不少
无 .pyc / __pycache__ / test/ / 编辑器残留干净
解包后能 import、能注册工具与技能不依赖源码目录结构
解包后 py/*.py 能独立运行同上
files 写的是 py/*.py 而非 py见下

files 为什么不能写裸目录。 实测:files: ["py"] 会把 py/__pycache__/*.pyc 一起打进去(本机有 3 个 Python 版本的字节码); 而且 .npmignore 在 files 白名单模式下对已纳入的目录不生效——这一条与 直觉相反,是实测结论。改成 py/*.py 才真正排除,包体积从 121.9 kB 降到 107.1 kB。

当前包的 20 个文件:LICENSE、README.md、cordis.patch.yml、package.json、 icon.svg、lib/index.js、lib/toolkit.js、py/*.py(12 个)、skills/docx2md/SKILL.md。

从 tarball 安装这条路径

test/github-install.mjs 验证:打包 → 干净 profile 里 pnpm add → 检查 node_modules 内容 → 加载并注册 → 真跑一次 inspect → pnpm remove 后无残留。

三个只在实测中才会暴露的坑,都写进脚本注释了:

坑现象解法
UNC 路径下建符号链接失败os error 1(函数不正确)测试环境放原生 Windows 路径
Node 不能直接 spawn .cmdEINVAL / ENOENT经 cmd.exe /c 调用 npm 与 pnpm
pnpm 拦截包内 skillAgent skills awaiting approval功能不受影响;可 pnpm approve 复核

第三条是 pnpm 对第三方包声明 skill 的安全机制,不是插件缺陷,但用户会看到这条 提示,值得在 README 里说明。

安装时看到「Agent skills awaiting approval」怎么办

pnpm 12 起会对第三方包内声明的 skill 做一次人工确认,安装后打印:

Agent skills awaiting approval: dsh-docx2md@...
Run "pnpm approve" to review them.

这是 pnpm 的安全机制,不是安装失败,工具与技能都已经装好了(test/github-install.mjs 在出现这条提示的情况下仍实测通过:3 个工具 + 1 个技能注册正常,能处理真实文档)。 这条提示是 pnpm 每个依赖(包括其他插件)一共汇总,不是本包专属。

想消掉它,在该 profile 目录执行一次:

cd ~/.dsh/profiles/desktop
pnpm approve

它会列出待批准项,按提示确认即可。若不想交互,也可在 pnpm-workspace.yaml 里 把本包加入 onlyBuiltDependencies / 相应的允许列表(具体字段随 pnpm 版本变化, 以 pnpm approve 的提示为准)。

测试的可移植性

test/ 里的脚本原先写死了 C:/Users/<某用户>/...。现在全部通过 test/lib/discover.sh 解析:

需求做法
一次性目录mktemp -d,落在系统临时目录
Windows 路径to_win_path 把 /mnt/c/... 换算成 C:\...
本机 DSH 位置环境变量 → 自动发现 → 明确跳过
Windows node / pnpmfind_win_node / win_pnpm

「明确跳过」是成功而不是失败。 需要 Windows 环境的测试在 Linux/macOS 上会 打印 skip ... 并返回 0——否则别人 clone 后跑测试会莫名变红。

sync-check.sh 尤其如此:它检查本机 DSH 的三处代码是否同步,对使用者本来就 不适用,找不到目标时直接跳过。

仓库卫生

.gitignore 排除 __pycache__/、*.pyc、*.tgz 等。没有它时 .pyc 会被提交 进版本历史——之后再加 .gitignore 也只是停止追踪新文件,已有记录仍在。

开发用的 sync-check.sh 不进包

test/sync-check.sh 检查「源码 → 安装副本 → node_modules」三处是否一致,以及 宿主进程是否需要重启。它依赖本机的 pnpm file: 硬链接、宿主进程名和 ~/.dsh 路径,是开发环境专用的,不出现在 tarball 里(test/ 整目录都不进包)。

发布者用不到它;改代码后用它避免「改了没生效」的假验证:

./test/sync-check.sh          # 退出码 0=可验 / 1=代码不同步 / 2=需重启
./test/sync-check.sh --fix    # 自动同步源码到安装副本

中文编码:为什么单测要在伪造的 GBK 下跑

中文 Windows 上 Python 的 sys.stdout.encoding 默认是 gbk,非 ASCII 字符会被编成 GBK 字节;接收方按 UTF-8 解码就得到 ����。这个故障是静默的,而且只在中文 Windows 上出现 —— 在 Linux/macOS 上永远不会复现(那里恒为 UTF-8)。

它的后果不只是「显示难看」:docx2md_inspect 的 missing_preview_context[].paragraph_text 是判断「缺图公式属于图注还是正文」的 唯一依据,乱码等于这个判断失效。

三层防御:

层做法位置
进程层给子进程注入 PYTHONUTF8=1 / PYTHONIOENCODING=utf-8lib/index.js pyEnv()
脚本层每个脚本入口调 force_utf8_stdio(),不依赖环境变量py/docxfile.py
接收层严格 UTF-8 解码,非法字节直接报错而非替换成 U+FFFDlib/index.js decodeStrict()

回归测试用 PYTHONIOENCODING=gbk 在 Linux 上伪造同一环境(实测该设置会让 python3 把「位错」写成 GBK 字节,与 Windows 中文版一致),因此这条路径可在 任意平台复现:

node test/encoding.mjs                 # 用内联的微型 docx
node test/encoding.mjs 真实文档.docx    # 再验一份中文文档

实测基线(145 公式的论文):

阶段耗时
inspect~0.4 s
extract~13 s(含 127 张图渲染)
build + validate~0.7 s

性能实测

在 Windows 桌面版 DSH + Python 3.13 + uvx 兜底的组合下:

操作输出到 Windows 本地输出到 WSL 路径
inspect(145 公式)5.2 s—
extract + 渲染 127 张图4.9 s9.9 s
build + validate0.75 s—

把工作目录放在 Windows 本地比放在 WSL 路径快一倍(GDI+ 经 UNC 写图有固定开销)。 文档本身放 WSL 没问题,但 outDir 建议给个 Windows 本地路径。


已知限制

  • EMF 内嵌 PDF 文字层未实现。「三种手段」一节里提到这条路径(★★★★, 变体 B 的解法),但它在原始实验里是手工做的一步,没有固化进本插件;pypdf 目前只是预留依赖,py/ 下零引用。因此 15/145 的变体 B 公式只能靠渲染图读, 读不清时 extract 的 summary.formulas_without_media 会报出数量,不会假装成功。 详见 docs/methodology.md 开头的说明。

  • 只吃 .docx。旧 .doc 请先用 Word / WPS 另存。非 zip、空 zip、缺 word/document.xml、加密文档都会给出可读错误并以退出码 3 结束。 Python 脚本失败时不会留下空目录(文档校验在建目录之前);但工具层 docx2md_extract 会先按 outDir 建目录再调用脚本,所以经工具调用时可能 留下一个空目录——里面不会有任何产物。

  • 同一 outDir 会被清理。换文档复用目录时,旧的 paras.json/media//png/ 以及人工确认过的 headings.json 都会被删除——段落号对另一份文档没有意义, 留着会让新文档的标题挂到错误的段落上。

  • 无预览图的公式无法自动转换(仅 OLE 族;OMML 从 XML 转出,不需要预览图)。 inspect 会报出数量,需要看原 Word 手工补。

  • OMML 转换覆盖常用结构,未知结构降级拼接。分式、上下标、根式、定界符、 大运算符、函数、重音、横线、矩阵、方程组、underset/overset、纯文本样式、 希腊字母与常用符号(22 类逐一测试)。遇到不认识的节点时拼接其子节点内容—— 宁可少一层装饰,绝不丢内容、绝不抛异常。降级发生时该公式仍会出现在产物里, 但可能缺外层结构;对关键公式建议抽查。

  • 图片公式无法自动转换,但绝不静默丢。截图/扫描进来的公式在 docx 里 就是一张位图,像素没有结构可解。工具做的是:按形状启发式(扁条状行内图: 宽高比≥2.5 且高≤120px)识别候选、生成 ⟦EQ:img:文件名⟧ 占位、把原件 抽进 media/;Agent 读图转写后以 "img:文件名" 为键回填 LaTeX。什么都不做 也不丢——build 会落成可见的图片引用(不是藏进注释),校验器 T11 保证引用 不断链。候选是启发式不是结论:行内小图标也可能被算进来, image_inventory.json 带 ratio/尺寸/上下文供人逐个过目。

  • 预览图必须按 ShapeID 配对,不能按顺序。公式的预览图是带 o:ole="t" 的 v:shape,其 id 等于 OLEObject 的 ShapeID。早期实现按「同段落第 k 个 公式配第 k 张图」,在公式前面夹着插图的段落里会整体错位,把插图位图当成公式 预览图交给模型去读(实测 145 个公式里错 10 个,且不报错)。现在优先按 ShapeID 配对;只有文档不写 ShapeID 时才退回下标配对,并在 summary.formulas_paired_by_ordinal 与 pairing_warning 里明确报出来。

  • 标题识别读的是 docx 的结构,不只是正则。中文文档 1. / A) / i) / 无编号 「引言」混用是常态,更麻烦的是大量标题在段落文字里根本没有编号:Word 的自动 编号(<w:numPr>)是渲染时现算的,样式标题(pStyle=heading N / outlineLvl) 的文字跟正文一模一样。所以 extract 会额外落一个 docstruct.json(样式级别 / 合成出的自动编号 / 整段加粗),build 拿它补足纯正则认不出的标题。 实测同一份文档:纯正则 12 条 → 现在 27 条(人工整理稿 32 条,差额是参考稿把 附录与摘要也当节了)。 自动编号会被补回标题文字(iii) 位错密度的演化方程)——那段编号本来就该显示, 只是 docx 没把它存成字符;这是保真不是编造,值完全由 numbering.xml 推出。 只有加粗、无编号无样式的短标签(攀移力)默认也算标题,但要过两道判据: 不超过 15 字、且下一段是正文而不是公式(位移场 这类公式行标签因此被挡住)。 嫌吵用 --no-bold-headings 关掉。识别结果仍是初稿,--suggest 后再人工确认。

  • 插图会进正文。示意图、流程图按原位落成 ![图](media/image3.png)(EMF/WMF 引用 png/ 里渲染好的 PNG),不再静默丢弃。判定「哪张是公式预览图」靠结构而不是 宽高比:公式预览是带 o:ole="t" 且 ShapeID 命中 OLEObject 的 shape,逐张判 (v:group 里公式预览与拼图子图会共存,按容器整体判必然错)。仍不要用文字 描述重画示意图——实测 7 轮全失败。

  • 按 OOXML 结构遍历,不假设「正文 = 一串段落」。表格转 GFM 表格(行列对齐、 gridSpan/vMerge 合并列留空、绝不复制内容),列表按 numPr 转 1. / -, 行内加粗/斜体/下划线/删除线/上下标/超链接/脚注都还原成对应 Markdown。 extract 额外落 blocks.json 记录正文的真实块结构,build 按它的顺序输出, 段落与表格的交错顺序因此保住。没有 blocks.json 时退回纯段落模式(兼容旧产物)。 这是把 docx 当纯文本读的假设被推翻后的结果:早期版本对一份 4 行 2 列的表格 会转出「表头A / 表头B / 单元格1 / 单元格2」四行散段,行列关系消失且不报错。

  • 强调标记必须能闭合。**应用公式 **(闭定界符前有空白)在 CommonMark 里 不闭合,星号按字面输出。docx 里整段加粗的 run 常带尾随空格,所以 build 会把 首尾空白挪到标记外面;校验器 T12 成对扫描定界符,内侧有空白就报错。

  • 只保证结构合规,不保证每个渲染器都认。docx2md_build 的 PASS 意味着 六条铁律与 T9/T10 都过了,但不同 Markdown 预览器对数学语法的支持差异很大。 交付前务必在目标渲染器里打开看一次,尤其是长公式与矩阵。

    现象原因查什么
    公式显示成 LaTeX 源码 / 整块空白$ 与中文紧贴,渲染器没识别成公式validation.warnings 里的 T10
    产物里出现 ![图片公式 …]该公式在 Word 里就是张图,未转写(不丢机制在起作用)build 返回的 image_placeholders
    图片公式引用是破图引用目标文件不存在validation.errors 里的 T11
    显示成 \begin{aligned} 原文块级环境写进了行内公式T2
    只有长的、矩阵的坏同上,或该渲染器不支持某些命令T2 + 该渲染器的 KaTeX 版本

    实测教训:katex.renderToString 能渲染,不等于 Markdown 解析器会把 它识别成公式。这是两层独立判定,必须分开测。


目录结构

dsh-docx2md/
├── lib/
│   ├── index.js       插件主体:注册 3 个工具 + 1 个技能
│   └── toolkit.js     defineTool 的零依赖实现
├── py/
│   ├── docxfile.py    .docx 打开与结构校验(坏输入转可读错误)
│   ├── docx_scan.py   只读扫描(OLE / OMML / 图片候选三类分开计数)
│   ├── rich.py        按 OOXML 块级结构读取(表格/行内格式/链接/脚注/修订)
│   ├── docstyle.py    段落结构信号(样式级别 / 自动编号 / 整段加粗)→ 判标题
│   ├── imgsize.py     图片自身像素尺寸(判「扁条公式还是示意图」)
│   ├── extract.py     正文块 + 公式 + 预览图 + 插图(OMML 在此转出 LaTeX)
│   ├── omml.py        OMML → LaTeX 确定性转换器
│   ├── imgformula.py  图片公式候选检测(形状启发式 + OLE 预览排除 + 画布内公式识别)
│   ├── render.py      跨平台 WMF/EMF → PNG
│   ├── build.py       块结构 + LaTeX 表 → Markdown(表格/列表/行内格式)
│   └── validate.py    六条铁律 + T9–T14 校验
├── skills/docx2md/
│   └── SKILL.md       技能说明(给 Agent 读)
├── docs/
│   └── methodology.md 完整方法论(转换实验总结)
├── test/              全部测试(不进发布包)
├── cordis.patch.yml   插件注册
└── CHANGELOG.md

lib/ 只用 Node 内建模块。这不是洁癖——第三方插件装在 profile 的 node_modules 下,而 DSH 自身的包在 app.asar 里,标准 Node 解析不到 @deepseek-ai/*。详见 lib/toolkit.js 顶部注释。


License

MIT

관련 플러그인