Docling Word 公式导出回归规范解读:同一段落多个 `<m:oMath>` 如何拆分为独立公式块
本文以仓库 tests/data/docx/groundtruth/omml_multi_equation_paragraph.docx.md 为对象,讲解 Docling 在把 Word(.docx)文档中的 OMML 数学公式导出为 Markdown/LaTeX 时,如何处理“同一个段落内并列存在多个公式”这一边界场景:它被定义为回归基准(ground truth),要求每个
<m:oMath>元素都渲染成一个独立的$$...$$公式块,而不是被拼接成一个块。读完本文,你将了解 OMML→LaTeX→DoclingDocument→Markdown 的完整链路、后端源码中逐公式拆分的实现位置,以及该规范文件在自动化测试中如何被校验与再生成。
这份 ground truth 文档是什么
omml_multi_equation_paragraph.docx.md 是 Docling 测试资产体系中的一份 Markdown 回归基准文件,它对应同一目录下由 Word 源文件转换而来的三份产物之一:
| 文件 | 作用 |
|---|---|
| omml_multi_equation_paragraph.docx | 测试输入源文档(.docx) |
| omml_multi_equation_paragraph.docx.json | 转换后的 DoclingDocument 结构化基准 |
| omml_multi_equation_paragraph.docx.itxt | 缩进文本形式的文档树基准 |
| omml_multi_equation_paragraph.docx.md | export_to_markdown() 输出的 Markdown 基准(本文主题) |
该文件内容描述了一个被命名为 “Issue 3: Concatenated equation blocks” 的回归场景:
段落下含三个彼此独立的
<m:oMath>元素,且它们是单个<w:p>内部的同级(sibling)节点。期望输出为三个独立的$$...$$公式块($$a = b$$、$$c = d$$、$$e = f$$);历史缺陷行为是把所有公式拼进同一个$$块。
文件正文中既有对该问题的文字性描述(这段文字本身也来自 Word 源文档的正文内容,因此以 HTML 实体 <m:oMath>、<w:p> 的形式被 Markdown 导出器转义),也给出了规范化的期望结果,即三个分立的 $$ 代码块:
$$a=b$$
$$c=d$$
$$e=f$$
注意一个容易混淆的点:这份 .md 是当前 Docling 代码实际导出结果的“金标准”快照,而不是对期望的“空想”。其中描述缺陷行为的句子是测试夹具(fixture)作者在 Word 正文里留下的说明文字,用于记录问题背景;真正要回归验证的是后面三个公式块的物理形态——必须是三个独立块。
背景:Docling 如何把 Word 公式变 Markdown
Word 文档中的公式使用 Office Open XML 的 OMML(Office Math Markup Language) 描述,命名空间为 http://schemas.openxmlformats.org/officeDocument/2006/math,公式根元素即 <m:oMath>,它们可以内嵌在段落 <w:p> 中。Docling 的处理分两步:
- Word 后端解析:由 msword_backend.py 中实现的后端负责把 docx 的 WordprocessingML 树转换为
DoclingDocument。它识别段落中每个<m:oMath>,调用 OMML→LaTeX 转换器得到 LaTeX 串,再按“行内公式 / 独立公式 / 列表内公式”等语义写入文档树。 - OMML→LaTeX 转换:核心实现在 docling/backend/docx/latex/omml.py,由
oMath2Latex类把各类 OMML 数学结构翻译成 LaTeX(分数f、上/下标sSub/sSup/sSubSup、根式rad、定界符d、矩阵m/mr、求和/积分等 n 元运算符nary、上下限limLow/limUpp/lim、函数应用func/fName、方程阵列eqArr等)。
在 OMML 一侧,omml.py 暴露了低层入口 load(stream) 与 load_string(string),二者都通过 tree.findall(OMML_NS + "oMath") 逐个取出 <m:oMath> 元素并 yield str(oMath2Latex(omath))(见 omml.py)。“一次取出一个 oMath、逐个产出 LaTeX 字符串”的迭代模型,正是本文场景在规范层面应当成立的前提。
源码级拆解:逐公式拆分到底发生在哪
该场景的实际“修复/实现”发生在 msword_backend.py,有两个关键点。
第一步:把段落里的公式抽出来并打上占位标记
_handle_equations_in_text 负责扫描一个段落的子节点:先以直接子节点迭代收集 <m:oMath>(排除 <m:oMathPara>),只有直接层找不到公式时才回退到深层迭代,以兼容公式被包裹在 oMathPara 等包装元素里的情形。每找到一个 <m:oMath>,就将其用 oMath2Latex 转为 LaTeX 字符串,并以默认的公式书签模板封装:
self.equation_bookends: str = "<eq>{EQ}</eq>"
(见 msword_backend.py 与 _handle_equations_in_text。)
于是“三个并列 <m:oMath>”在这个阶段会被抽出为三个独立的 LaTeX 串,分别包装成 <eq>a=b</eq>、<eq>c=d</eq>、<eq>e=f</eq> 之类的占位序列,而不是被合并进同一条文本。
第二步:独立公式段落按“一公式一项”落库
在段落主处理逻辑中,当某段落“没有普通文本、只含公式”时进入独立公式分支(msword_backend.py):
elif len(equations) > 0:
if (paragraph.text is None or len(paragraph.text.strip()) == 0) and len(text) > 0:
# Standalone equation(s) — emit each as a separate formula
...
if len(equations) > 1:
for eq in equations:
eq_text = eq.replace("<eq>", "").replace("</eq>", "").strip()
if len(eq_text) > 0:
t1 = doc.add_text(
label=DocItemLabel.FORMULA,
parent=parent,
text=eq_text,
content_layer=self.content_layer,
)
elem_ref.append(t1.get_ref())
关键点是:当抽取出的公式多于一个时,循环逐个 doc.add_text(...),每个公式都是独立的 FORMULA 文本项;仅当只有一个公式时走“单条文本整块写入”的简分支。这就从数据结构上杜绝了“多公式被拼成一个块”的历史问题。
从源码结构看,这段多公式拆分逻辑是在某个回归修复中加入的——因为其注释明确写着 “Standalone equation(s) — emit each as a separate formula”,且下面的 if/else 正是针对“多公式 vs 单公式”的分流。
基准产物互相印证:JSON、itxt 与 MD
同一个夹具的三份基准文件给出了互相一致的证据,说明“三个独立公式项”已经反映在 Docling 的数据模型与两种导出视图里:
- JSON 基准 omml_multi_equation_paragraph.docx.json:在
body.texts中,#/texts/0是标题“Issue 3: Concatenated equation blocks”,#/texts/1是问题描述正文,而#/texts/2、#/texts/3、#/texts/4是三个label: "formula"的独立文本项,orig/text分别为a=b、c=d、e=f。 - itxt 基准 omml_multi_equation_paragraph.docx.itxt:在缩进树中依次出现三行
item-x at level 3: formula: a=b/formula: c=d/formula: e=f,它们挂在section_header这一层之下。 - MD 基准(本文对象):三个独立文本项最终被序列化为三个彼此以空行分隔的
$$...$$公式块。
三者对照可以得出可验证的结论:该夹具的期望行为在数据模型层是“三个 FORMULA 项”,在 Markdown 层是“三个 $$ 块”,而不再是“一个含全部公式的整块”。在 docling/datamodel/document.py 中还可以看到 DocItemLabel.FORMULA 在文本化文档里被记作 "equation",这与公式语义在全文档中的标签化呈现是自洽的。
这份规范在测试中如何被使用
这类 .docx.md 基准并不只是给人看的文档,而是端到端转换测试的期望输出:
- 测试框架通过 tests/groundtruth_paths.py 依据源文件路径推算出同名的
.json/.md等基准路径(get_regular_groundtruth_paths把input_path.parent.parent / "groundtruth"下的同名文件定位为基准)。 - 在验证工具 tests/verify_utils.py 中,将转换结果调用
doc_result.document.export_to_markdown(compact_tables=True),再与gt.md路径指向的基准做规范化比较(如换行归一化),从而锁定 Markdown 导出的字节级行为。 - 若需要重生成这些基准(例如未来修改了导出器并有意更新规范),仓库支持通过环境变量
DOCLING_GEN_TEST_DATA触发数据再生成——该开关在 tests/test_data_gen_flag.py 中定义(os.getenv("DOCLING_GEN_TEST_DATA", 0)解析为布尔量),供开发者用于重建 ground truth 而不是断言。
因此,任何未来改动如果让“同段多公式”又变回“拼接成一个 $$ 块”,都会直接导致该 .md 基准比对失败——这正是这类“问题型回归夹具”存在的意义。
系列化的公式回归夹具
omml_multi_equation_paragraph 并非孤例,它在 tests/data/docx/groundtruth/ 下属于一套编号的问题回归族,同族的规范文件还有:
- omml_frac_superscript.docx.md:Issue 1——分数作为上标底数时未加分组括号,期望
{\frac{(x-c)}{v}}^2;对应地,在 omml.py 中可看到_needs_grouping会检测\frac/\sqrt并为其补上{...}分组。 - omml_text_escapes_in_math.docx.md:Issue 2——数学 run 中的 EN DASH(U+2013)等被转成文本模式宏
\textendash,期望保持数学语义输出x - y^2;对应地,omml.py 中_MATH_CHAR_MAP把\u2013、\u2014、\u2212归一为-、把\u005e归一为^,并在process_unicode/do_r里做数学上下文下的还原。 - omml_func_log.docx.md:Issue 4——
<m:func>中fName='log'期望得到y = \log(x),避免被当作斜体变量逐字母输出。
把 Issue 3 与这些兄弟夹具放在一起看,可以明确一件事:Docling 对 Word OMML 公式的测试采用了“把每个已知缺陷固化成独立源文档 + JSON/MD/itxt 三重基准”的工程模式,每一份 .md 既是规范的文本表达,又是 CI 中可执行的比对对象。omml_multi_equation_paragraph.docx.md 在其中承担的角色,就是锁定“一公式一块”的块粒度语义。
如何在自己的环境复现验证
如果你想亲手验证本文描述的规范行为,可以按以下只读、可运行的方式操作:
- 安装 Docling 并准备一份含公式的
.docx(仓库已提供现成夹具,见 tests/data/docx/sources/omml_multi_equation_paragraph.docx)。 - 命令行转换并导出 Markdown:使用 Docling CLI 把该 docx 转成文档对象后再导出 md(CLI 参考见 docs/reference/cli.md;编程式用法参见 examples/minimal.py,核心是
DocumentConverter().convert(path)与result.document.export_to_markdown())。 - 对照本文基准:将输出与 omml_multi_equation_paragraph.docx.md 比对——正常输出应在描述文字之后出现三个以空行分隔的
$$a=b$$、$$c=d$$、$$e=f$$块。 - 运行回归测试:在仓库测试目录执行与 docx 后端相关的端到端用例(如
pytest tests/test_e2e_conversion.py -k msword一类针对 Word 的用例),确认转换 md 与基准一致;如需查看测试对 md 基准的比较逻辑,可阅读 tests/verify_utils.py。
需要提醒的适用前提是:上述拆分行为针对的是“段落内无正文、仅含并列公式”的结构(即代码中 paragraph.text 为空、但抽取文本非空的分支);若公式与普通文字混排在同一段,Docling 会走行内公式(inline_group)路径,此时一个段落里出现多个 <m:oMath> 的呈现语义会由行内公式处理逻辑决定,而不是本基准覆盖的独立公式块情形。
小结
omml_multi_equation_paragraph.docx.md 这份看似只有几行的基准文件,实际上编码了一条明确的导出契约:Word 中同一个 <w:p> 内并列的多个 <m:oMath>,在 Docling 中必须各自成为一个独立的公式文本项,并在 Markdown 序列化时呈现为多个 $$...$$ 块。该契约在数据模型(JSON/itxt)与导出层(MD)得到三方印证,其实现根因可以追溯到 msword_backend.py 的“Standalone equation(s) — emit each as a separate formula”分支。对二次开发者或复用 Docling 公式能力的开发者来说,理解这条规范与它的测试锚点,有助于判断自己在 docx 数学内容处理上的改造是否会破坏 Docling 既有的公式语义边界。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0626
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00