DOCX 转 Markdown:视觉样式为什么不等于文档语义
把一行字放大、加粗,并不等于给它声明了标题层级。DOCX 转换器如果只根据外观猜结构,可能把警告、金额或者封面文字误判为标题;如果只读取明确的结构,又可能漏掉作者心目中的“标题”。这个取舍不能靠一句“保留格式”交代清楚。
本篇是《Markdown 解析与工程实践》第 16 篇。下面用可重复的小文件区分三件事:源文档声明了什么、DOCX 读取器识别了什么、Markdown 序列化阶段又保留了什么。
最小复现:两种不同的标题
第一份文件的段落使用 Heading 1;第二份文件只把文字设成 16pt 和粗体。使用 docx 9.7.1 生成,关键输入为:
new Paragraph({
heading: HeadingLevel.HEADING_1,
children: [new TextRun('Real heading')]
})
new Paragraph({
children: [new TextRun({
text: 'Visual heading', bold: true, size: 32
})]
})
这里 size 使用半磅单位。它们不是经过像素校准的“完全相同外观”样本;实验只比较显式标题样式与直接文字格式,不做视觉一致性结论。
Mammoth 1.12.0 输出的结构分别是:
<h1>Real heading</h1>
<p><strong>Visual heading</strong></p>
再交给 Turndown 7.2.4,设置 headingStyle 为 atx,得到 # Real heading 与 **Visual heading**。后者没有产生标题节点。这不是字符漏读,而是结构声明不同。
原理:段落属性和文字属性不是一个层级
Microsoft 的段落说明介绍了 WordprocessingML 的段落及其属性。文字运行说明则说明一个段落中的 run 可以带有各自的文字属性。
在本次生成文件中,真实标题的 document.xml 含有 w:pStyle,引用 Heading1;视觉标题只有 run 中的 w:b 和 w:sz。判断标题时读哪个信号,直接决定转换结果。列表还需要编号属性和编号定义;不能因为文本以数字开头就断言它是一个列表。
Mammoth 的设计说明明确侧重语义,而不是逐像素还原。实际工程中仍要检查自定义样式的映射:存在一个有名字的样式,不代表每个读取器都认识它。
固定版本实验:不能只看最终截图
测试日期为 2026-09-17。生成器为 docx 9.7.1;两条 DOCX 读取路径为 Mammoth 1.12.0,以及 LibreOfficeDev 26.8.0.0.alpha0(构建 2c87e51eeaa2b413ff4ae097b2705eea1995d8e5)的 HTML 导出。后者是开发构建,结果不能代表所有稳定版。Markdown 阶段使用 Turndown 7.2.4,未启用表格插件。
以下比较的是实际生成的 HTML 结构,不是应用截图:
| 输入 | Mammoth 默认输出 | LibreOffice HTML 输出 |
|---|---|---|
| 正常:Heading 1 段落 | h1 | h1 |
| 视觉格式:粗体、16pt | p 内 strong | p 内字体与 b 标签 |
| 困难:自定义 Section Label,含大纲级别 0 | p,并报告未知样式警告 | 带视觉格式的 p |
| 边界:空 Heading 1 | 不输出空标题 | 保留含换行的 h1 |
困难文件还含两级编号列表和一个 2×2 表格。两条 HTML 路径都出现了列表与 table 结构,但标记组织和展示属性并不相同。这只是一个小样本,不构成解析器兼容性排名。
另一个失败输入是把普通文本保存为 invalid.docx。Mammoth 报 ZIP 中央目录错误,没有成功输出;这证明扩展名不是文件有效性的依据。没有测试密码保护文件,因此不能把这条错误结论推广到加密 DOCX。
自定义样式需要显式契约
困难文件的 Section Label 在 Mammoth 中产生 Unrecognised paragraph style 警告。加入下面的映射后,同一段落才转换为 h1:
const result = await mammoth.convertToHtml(
{ buffer },
{ styleMap: [
"p[style-name='Section Label'] => h1:fresh"
] }
)
这比“所有 16pt 粗体段落都变标题”更可控,但前提是团队确实把 Section Label 当作一级标题使用。映射属于文档模板与转换程序之间的契约,不能根据名字替陌生文件作者作决定。
工程影响:表格可能在第二阶段丢失
我把同一组文件交给自己开发的 MDFold Word 转 Markdown 页面复核。线上正常标题、视觉标题、列表和空标题的结果与这条 Mammoth → Turndown 路径相符。但困难文件中的表格最终变成了分开的 Key、Value、A、7,行列关系没有保留。
这次本地中间 HTML 仍含 table,说明该样本的损失发生在后续 Markdown 转换阶段,不能简单归因于 DOCX 读取失败。Turndown 文档提供表格等扩展的插件接口;选择扩展后仍要重新验证目标 Markdown 方言,而不是把“加插件”当作所有复杂表格的保证。
失败流程还有一个界面风险:成功转换边界文件后再选择 invalid.docx,线上显示了错误,但仍保留上一份文件的输出,复制和下载按钮依然可用。不要把保留的旧结果当成新文件的转换结果。这是本次发现的待修问题,不是已修复状态。
建议:为每一层设断言
- 源文件层:检查段落样式、编号层级及实际内容,不用字号代替结构。
- DOCX → HTML 层:检查 h1、列表嵌套、table 和警告;记录未知样式。
- HTML → Markdown 层:再次验证标题、行列关系、链接和末尾标记。文本还在,不代表结构还在。
- 界面层:失败时明确区分旧输出与新结果,避免导出错误归属的内容。
本次没有覆盖图片、修订、脚注、合并单元格和文本框,也没有做速度或隐私流量审计。因此不应据此宣称复杂 Word 文档能完整迁移。桌面与 390px 页面检查只能证明测试时的界面可读性,不能代替这些内容验收。
最后留下一个取舍:转换器应该猜测视觉结构,还是忠实保留显式语义?如果允许猜测,是否应该把它作为可审查的建议,而不是悄悄写入最终 Markdown?

浙公网安备 33010602011771号