首页
/ Docling Word 公式导出回归规范解读:同一段落多个 `<m:oMath>` 如何拆分为独立公式块

Docling Word 公式导出回归规范解读:同一段落多个 `<m:oMath>` 如何拆分为独立公式块

2026-09-07 14:35:16作者:郦嵘贵Just

本文以仓库 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 实体 &lt;m:oMath&gt;&lt;w:p&gt; 的形式被 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 的处理分两步:

  1. Word 后端解析:由 msword_backend.py 中实现的后端负责把 docx 的 WordprocessingML 树转换为 DoclingDocument。它识别段落中每个 <m:oMath>,调用 OMML→LaTeX 转换器得到 LaTeX 串,再按“行内公式 / 独立公式 / 列表内公式”等语义写入文档树。
  2. 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=bc=de=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 基准并不只是给人看的文档,而是端到端转换测试的期望输出

  1. 测试框架通过 tests/groundtruth_paths.py 依据源文件路径推算出同名的 .json / .md 等基准路径(get_regular_groundtruth_pathsinput_path.parent.parent / "groundtruth" 下的同名文件定位为基准)。
  2. 在验证工具 tests/verify_utils.py 中,将转换结果调用 doc_result.document.export_to_markdown(compact_tables=True),再与 gt.md 路径指向的基准做规范化比较(如换行归一化),从而锁定 Markdown 导出的字节级行为。
  3. 若需要重生成这些基准(例如未来修改了导出器并有意更新规范),仓库支持通过环境变量 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.mdIssue 1——分数作为上标底数时未加分组括号,期望 {\frac{(x-c)}{v}}^2;对应地,在 omml.py 中可看到 _needs_grouping 会检测 \frac / \sqrt 并为其补上 {...} 分组。
  • omml_text_escapes_in_math.docx.mdIssue 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.mdIssue 4——<m:func>fName='log' 期望得到 y = \log(x),避免被当作斜体变量逐字母输出。

把 Issue 3 与这些兄弟夹具放在一起看,可以明确一件事:Docling 对 Word OMML 公式的测试采用了“把每个已知缺陷固化成独立源文档 + JSON/MD/itxt 三重基准”的工程模式,每一份 .md 既是规范的文本表达,又是 CI 中可执行的比对对象。omml_multi_equation_paragraph.docx.md 在其中承担的角色,就是锁定“一公式一块”的块粒度语义

如何在自己的环境复现验证

如果你想亲手验证本文描述的规范行为,可以按以下只读、可运行的方式操作:

  1. 安装 Docling 并准备一份含公式的 .docx(仓库已提供现成夹具,见 tests/data/docx/sources/omml_multi_equation_paragraph.docx)。
  2. 命令行转换并导出 Markdown:使用 Docling CLI 把该 docx 转成文档对象后再导出 md(CLI 参考见 docs/reference/cli.md;编程式用法参见 examples/minimal.py,核心是 DocumentConverter().convert(path)result.document.export_to_markdown())。
  3. 对照本文基准:将输出与 omml_multi_equation_paragraph.docx.md 比对——正常输出应在描述文字之后出现三个以空行分隔的 $$a=b$$$$c=d$$$$e=f$$ 块。
  4. 运行回归测试:在仓库测试目录执行与 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 既有的公式语义边界。

登录后查看全文
热门项目推荐
相关项目推荐