很长时间里,我都把"跨平台编辑器怎么兼容Word公式"和"手写公式识别"当成两条独立的技术路线来处理。直到有一次,用户把论文里十几页的Word公式贴进我们的Web编辑器,格式全部乱掉;紧接着另一位同事又丢来一张手写公式草稿,希望识别成可编辑公式后直接放进Word文档。两个需求碰到一起,我才真正意识到:它们其实是同一套公式内核的两条入口,分开做只会让系统变得又脆又难维护。这篇文章就把我过去大半年折腾"跨平台编辑器、Word公式兼容、手写公式识别"三者协同的经验完整拆开讲透,适合做文档类产品、在线编辑器、科研工具的同学参考,也适合想弄明白公式在Word里到底以什么形态存在的人。
1. 先搞清楚Word公式到底是个什么东西
1.1 OMML:2007年之后的"公式心脏"
从Word 2007开始,Word里的公式就不再是普通图片,也不再是简单的文本拼接,而是用一种名为OMML(Office Math Markup Language)的XML方言来存储。它在OOXML(Office Open XML)体系之下,命名空间是m:。你会发现,一个简单的分数:
<m:oMath> <m:f> <m:num> <m:r><m:t>a+b</m:t></m:r> </m:num> <m:den> <m:r><m:t>c</m:t></m:r> </m:den> </m:f> </m:oMath>在Word里显示成(a+b)/c那样一个带分数线的数学对象。OMML的厉害之处在于,它的结构是高度语义化的:m:f表示分数,m:sSup表示上标,m:sSub表示下标,m:sSubSup表示同时有上下标,m:d表示带分隔符的括号结构。这种东西天生就是为"可编辑的数学结构"设计的,而不是只保存"看起来像什么样子"。
这个特性和普通文本的差异很大。普通文本在XML里就是一串字符,样式靠run属性;而公式是嵌套的树形结构。如果你做跨平台编辑器时,依然用"字符串+样式"的思维去处理公式,那你注定会踩坑。我见过不少编辑器把公式转成Base64图片或SVG存起来,Word原始公式导入后看起来没丢,实际上用户双击图片根本没法编辑,这显然不是"兼容"。
1.2 老古董Equation 3.0、MathType和AxMath的OLE包袱
Word的兼容性包袱,不在于2007年的新公式,而在于那些老公式对象。Equation 3.0是上世纪90年代的组件,本质是一个OLE对象,嵌入到Word文档后,存的是一段二进制数据,存放在docx包内的word/embeddings/目录里。MathType和国产的AxMath也一样,它们通过OLE嵌入Word,文档里留下的是一个w:object节点,指向一个.bin文件。
这意味着,如果你只是按OMML解析公式,遇到老文档或者第三方插件生成的公式,多半会解析失败。Mac上打开Windows发的旧Word时,老公式显示异常,就是因为目标机器上没有对应的OLE服务器。跨平台编辑器要处理这类对象,不能指望本地注册表里有MathType,只能把二进制数据提取出来,用专门的解析库去识别。有些库能应付,但很多情况下只能降级为图片兜底。这个"兜底"策略很重要,下面第5节我会再展开。
1.3 跨平台对公式兼容的直接影响
跨平台带来的第一个麻烦是字体和渲染差异。Windows上Word渲染公式用Cambria Math,Mac上的Pages和Word则可能回退到别的数学字体,到了Linux LibreOffice又是一套字体度量。同一个OMML,在不同平台的布局结果会有细微差别。
第二个麻烦是编辑器的宿主环境。Web里渲染公式靠HTML/CSS,桌面端靠原生控件,移动端靠Canvas或WebView。手写公式识别又依赖画布上的墨迹采集坐标。你没法在每个平台各写一套公式引擎,所以"公式内核统一、渲染层分平台适配"就成了唯一靠谱的路线。这也是我后面坚持用统一中间格式的根本原因。
2. 三种公式语言之间搭一座"共同中转站"而不是单向桥
2.1 OMML、MathML、LaTeX各自擅长的东西
做公式兼容,绕不开三种"公式语言":OMML是Word的原生格式,MathML是W3C标准、浏览器可读,LaTeX是学术圈通用、人类可写的文本格式。它们各有各的用途:
| 公式语言 | 优势 | 短板 | 典型场景 |
|---|---|---|---|
| OMML | Word原生,结构语义化完整,公式编号和文档样式一体 | 可读性差,非Office生态几乎不用 | Word文档内的公式 |
| MathML | W3C标准,语义稳定,浏览器可直接渲染 | 手写繁琐,人类几乎不会直接编写 | 网页展示、无障碍阅读 |
| LaTeX | 文本可读,生态工具多,转换库成熟 | 字符串表达损失结构语义,解析依赖上下文 | 论文排版、学术交流 |
那么问题是:编辑器在处理公式时,到底应该把OMML直接转成LaTeX,还是转成MathML?还是说,三条路都保留?
2.2 字符串往返转换的无损假象
一开始我的想法很简单:Word转成LaTeX,用户编辑,再转回OMML导出Word。这条"单向桥"初期跑得通,但没多久就暴露了问题。
首先是花括号和组的问题。LaTeX里{a+b}的空组在OMML里没有对应物;OMML里一个分子有多个项时,LaTeX写作\frac{a+b}{c},但更复杂的嵌套结构,比如“左侧有上下标、右侧有条件的方程”,转换容易丢嵌套层级。更麻烦的是分隔符。Word公式里的括号可以自动拉伸,OMML用m:d来标记;LaTeX里可以是\left(,但如果用户写的是普通(,转换器可能把它们当成不同的结构。
字符串层面往返一次还勉强能看,往返三次之后,结构就漂移了。比如带大括号的分段函数、矩阵的列对齐、多行公式的=对齐点,只要中间有一步做的是"文本替换"而不是"结构映射",总能漏掉一些信息。
2.3 统一公式AST:让所有输入法说同一种方言
踩了这些坑之后,我把架构改成了"统一公式AST"(Abstract Syntax Tree,抽象语法树)。不管是Word导入、手写识别、公式图片OCR,还是用户直接在编辑器里敲LaTeX,第一件事就是解析成同一套AST节点:
- 分数节点(
Frac) - 上下标节点(
Sup、Sub、SupSub) - 根式节点(
Sqrt、Root) - 分隔符节点(
Delimiter,记录左括号、右括号、是否拉伸) - 矩阵节点(
Matrix,记录行列数和对齐方式) - 多行节点(
MultiLine,记录每个公式行的对齐点)
有了AST这个"共同中转站",OMML、MathML、LaTeX都只是AST的表示形式。解析器负责把各格式翻译成AST,渲染器从AST生成HTML/SVG,导出器从AST生成OMML或LaTeX。这样,手写识别出的LaTeX、Word里的OMML、网页里的MathML,进入编辑器后都先归一化,再按用户需要导出。整个系统的思路就从"单向桥"变成了"巴别塔翻译中心",兼容性一下清晰了很多。
3. 手写公式识别从哪里接入才不会把架构搞乱
3.1 手写识别的完整管线拆解
手写公式识别,很多人以为就是一个OCR模型的事,实际拆开是四段管线:
第一段是墨迹采集。用户在画布上滑动时,前端要记录的不是一张图片,而是笔画的轨迹点序列,每个点至少包含x、y和时间戳t。时间戳尤其重要,因为笔画速度和停顿位置可以影响候选切分。第二段是预处理。原始轨迹有手抖和噪声,要做去抖、降采样、坐标归一化。坐标归一化这一步很关键,因为写字大小和位置会影响识别结果,归一化能显著提高稳定性。第三段是符号切分与分类。神经网络判断每一段轨迹对应什么符号,比如x、1、+、∫,同时还要判断哪些轨迹组成同一个符号,哪些只是连续笔画。第四段是结构分析。识别出符号之后,要判断符号之间的二维空间关系:谁在谁的右上方、谁在谁的下标位置、分母是哪一串符号。这一步常常用基于上下文的序列模型或规则引擎来做。
最终模型输出的结果,最好是LaTeX字符串或MathML,而不是一个扁平的符号列表。原因很简单:下一站就是统一AST,LaTeX可以无损解析进AST,而符号列表还需要重建结构。
3.2 Web端部署识别模型的取舍
既然要做跨平台,手写识别引擎就不能只跑在Windows桌面端。我的方案是优先保证Web端可运行,用的方式是ONNX Runtime Web,把训练好的识别模型转成ONNX格式,在浏览器里通过WebAssembly或WebGL推理。桌面端则可以直接复用同一个模型文件,不需要额外维护。
模型选型上,体量和精度要平衡。一个几十MB的模型在Web端还能接受,但如果是几百MB的大模型,页面首屏体验会非常糟糕。我的实测经验是,手写公式识别不像语音识别那样要求超大规模模型,重点在结构分析,一个中等规模的CNN+Transformer组合模型,配合好的后处理规则,准确率就能比较高。真正需要花心思的是后处理里的候选排序:比如1和l、0和O、乘号和字母x,都要靠上下文语法做约束,让存在歧义的识别结果进入候选列表,而不是直接给一个"死答案"。
3.3 识别结果进编辑器后的结构归一与纠错
识别结果不直接进入Word,而是先进AST,这算是架构上的一个"安全阀"。比如手写时经常出现多行公式里=号没有对齐,AST归一化阶段可以按用户选择自动对齐;再比如手写frac的分数线画得太短,AST里没有这个概念,只有分子分母的区域划分,渲染时自然会拉长分数线。
我还建议做一个"低置信度标记"机制。识别模型对某些符号没有把握时,输出一个置信度分数。低于阈值时,编辑器在对应符号下方画一个下划线提示;用户可以直接点击候选词进行替换。这个功能在真实使用中特别重要,因为手写体的个体差异是模型难以穷尽的。与其追求模型一次识别百分百准确,不如让用户能在十秒内完成纠错。
4. 把Word公式完整走通的关键链路
4.1 从docx包中找到公式的三种情况
docx本质上是一个ZIP文件。先用解压工具打开,定位到word/document.xml,然后遍历XML节点:
- 遇到
m:oMath节点,就是Word原生公式,走OMML解析器; - 遇到
m:oMathPara,这是独立的公式段落,通常承载块级公式; - 遇到
w:object节点,就要去检查word/embeddings/里有没有对应的.bin文件,这可能是老公式或MathType/AxMath生成的OLE对象。
写这个解析器的时候,最怕的是XML命名空间写错。document.xml里混着w:和m:两套命名空间,你按标签名搜oMath时,必须带上命名空间判断。很多人在这里栽跟头:直接用字符串查找<m:oMath>,遇到格式换行或属性插入就匹配失败了。正确做法是解析成DOM树或流式XML,再按命名空间和标签名双重匹配。
4.2 渲染层的MathJax与KaTeX选择
AST拿到手之后,要渲染到网页上。市面上有两套主流方案:MathJax和KaTeX。我的选择逻辑很简单——短文档、交互编辑场景用KaTeX,长文档批量渲染优先KaTeX,带复杂MathML需求时考虑MathJax。
| 对比维度 | MathJax 3 | KaTeX |
|---|---|---|
| 渲染速度 | 慢,首屏有延迟 | 快,尤其适合批量公式 |
| 支持的输入格式 | LaTeX、MathML、ASCIIMath | 以LaTeX为主,MathML支持有限 |
| 布局精度 | 高,对复杂结构支持充分 | 足够好,个别极端结构有差异 |
| 输出方式 | HTML+CSS、SVG | HTML+CSS |
| 体积 | 较大 | 更小 |
编辑器场景里用户频繁改写公式,渲染速度直接决定手感。KaTeX的缓存机制做得好,同一个公式重复渲染时几乎不耗时。MathJax胜在兼容性,如果你的文档包含复杂MathML特性,或者需要屏幕阅读器无障碍支持,MathJax更稳妥。
4.3 AST到OMML:导出Word的序列化要领
导出回Word才是完整闭环的最后一步。AST到OMML的序列化,有几个细节特别容易出错。
矩阵结构。OMML里矩阵的定义不叫"array",而是m:m,矩阵单元格是m:e,行是m:mr。转换AST里的Matrix节点时,每个单元格内容不管多复杂,都要完整套上m:e标签,并且要处理单元格内部可能是空的情况。
分隔符拉伸。Word里括号能否自动拉伸,取决于m:d节点的属性。AST里Delimiter节点要记录stretchy标志,导出的OMML才能保持"括号随内容变高"的行为。
还有一个让很多人忽略的问题:Word里的行内公式和块级公式。行内公式直接嵌在文本段落的m:oMath里,块级公式则用m:oMathPara包一层。AST里要给公式标记inline还是block,导出时才能放进正确的位置,否则排出来的公式全部挤在同一行。
5. 我在实际项目里踩过的一批坑
5.1 公式编号和Word域代码的"蒸发"问题
Word里的公式编号,看着像"(1-1)"这样的普通字符,但它往往藏着域代码(Field)。Word的自动编号是基于SEQ域的,在XML里表现为w:fldSimple或一段fldChar结构。你如果只解析公式而不解析域,导到非Word环境再导回去,编号很可能消失或变成静态文本。
我的处理方式是:解析阶段遇到域代码时,把它转成AST里的FieldNode,保留域类型和显示结果;导出到Word时再重新生成对应的域结构。如果实在不想碰域代码,至少要把显示结果转成普通文本,保证用户看到的东西没丢,只是失去自动更新能力。
5.2 行内公式与文字的基线错位
"公式与文字不对齐"这个坑,我在处理Word导入的文档时遇到过很多次。原因是多方面的:MathType生成的字体基线计算和Word原生字体不一致;中文字体与Cambria Math默认行高差异很大;行内公式里含有分式时,整体高度会超出普通文字行高。
我的解决方案分两层。解析时,从OMML的m:oMath节点读取基线信息,记录到AST节点属性里;渲染时,用CSS的vertical-align配合动态计算的行高做微调。实测下来,设置vertical-align: middle只对简单公式有效,遇到分式、上下标还是要计算公式的实际bounding box。所以更稳的做法是:渲染后测量公式元素的高度,再手动设置一个基于字号的偏移量。这个偏移量没有万能公式,要在你选择的数学字体下做一批样本回归测试。
5.3 被MathType/AxMath包过的公式识别不出来
用户的Word文档里,大量存在MathType公式,甚至两边都装了AxMath和MathType,插入公式时弹出的却是另一个工具的界面。这类文档的公式对象是OLE二进制,解析OMML的代码根本没用。
我试过两条路:一是用开源的OLE解析库尝试解出MathType的专用二进制格式,这条路能处理一部分简单的公式,但遇到嵌套结构、字体包嵌入时经常解析失败;二是渲染兜底——把OLE对象区域截成图片,用公式OCR模型识别成LaTeX,再进AST。后者准确率不一定100%,但至少能保住文本内容,而且配合低置信度标记让用户确认,实际运营效果还不错。
5.4 上万条公式在大文档里的换行与性能问题
有用户导入过一个包含几万条行内公式和大量数据表格的Word文档,页面直接卡到无法滚动。问题出在两个地方:一次性渲染了所有公式,以及重复解析公式字符串。我的修法是:
- 懒渲染:公式进入视口才渲染,用IntersectionObserver监听,离开视口后保留结果但不重排;
- 渲染缓存:同一个公式的LaTeX字符串作为key,渲染结果放在Map里,可能复用数十次;
- KaTeX替代MathJax:同样一批公式,KaTeX的渲染耗时要低一个数量级。
这几个改动组合起来,长文档的滚动流畅度提升非常明显。
6. 一个最小可行的落地顺序
6.1 第一阶段:先跑通"Word进入-编辑-导出Word"闭环
不要一上来就同时搞手写识别、OCR、MathJax,先把最核心的闭环打通:解压docx、定位m:oMath、转AST、网页渲染、用户编辑、AST转OMML、打包回docx。
这个闭环至少需要准备10个测试docx,覆盖:简单行内公式、分数根式、上下标、矩阵、多行公式对齐、公式编号、嵌套分隔符、MathType对象。每通过一轮,手动比对导入导出前后的公式结构,记录差异。
6.2 第二阶段:手写识别叠加进这个闭环
手写识别模块可以分两步走:先接入现有识别引擎的API或本地推理输出LaTeX,然后走LaTeX解析器进统一AST。前期的重点不是提高识别准确率,而是让"手写→LaTeX→AST→OMML导出"这一整条链路跑通。
不要急着把识别模块和Word导入模块耦合在一起。我吃过这个亏:一开始想让手写识别直接生成OMML,绕过了AST,结果两边结构对不上,最后不得不推翻重来。现在回想,让手写识别和Word解析都走AST,是一种典型的"优先后端归一、前端解耦"的工程决策,虽然初期多写一点转换代码,后期省下的调试时间却是成倍的。
6.3 测试集设计的黄金分法
不管你做公式转换还是手写识别,测试集都是保证质量的基石。我的做法是按公式类别分组:
- 线性公式:
a+b=c,无上下标; - 分数根式:
\frac{a}{b}、\sqrt{x^2+y^2}; - 上下标组合:
e^{i\pi}+1=0; - 矩阵行列式;
- 多行公式:
\begin{aligned} ...; - 分段函数:带
\left\{的分支结构; - 特殊符号:求和、积分、极限、希腊字母。
每组准备50个典型公式,标注好LaTeX、MathML和Word里的等价形式,跑回归的时候按组统计通过率。手写识别再加一组"真实路测集":让不同人用不同书写习惯写同一批公式,用来评估模型在不同字迹上的稳定性。真实路测集不在于大,而在于多样性——同样是x,有人一笔写完,有人两笔写完,识别管线都得接得住。
我自己在这个项目里最大的体会是:跨平台编辑器的公式兼容,从来不是"把A格式转成B格式"的翻译问题,而是"如何让不同来源的公式真正住进同一个结构里"的架构问题。手写识别、Word导入、OCR、键盘输入,各自只是这个结构的一条入口。先把结构定扎实,别怕多写转换层,后面的路会越走越顺。至于具体的模型选型、渲染方案,都可以在实际项目中边跑边调,因为真正决定产品体验的,永远是那条完整的链路,而不是某一个点的精度。