做投研平台的内容编辑模块时,最让我头疼的不是表格、不是K线截图,而是公式。分析师把Word里写完的周报、投资策略报告粘到XHEDITOR里,文字、图片、表格全都没问题,唯独公式不是消失就是乱码:要么变成一串带反斜杠的域代码,要么原地蒸发,要么缩成一张模糊的小图片。这个问题沉淀了大半个月才彻底解决,所以我把整条链路——从Word剪贴板里公式的真实存在形态、到XHEDITOR里如何拦截粘贴、再到把Word公式转成MathML后交给MathJax渲染——完整拆开记录,希望对做在线文档类产品、尤其是做金融投研平台编辑器的同学有帮助。
1. 想清楚再动手:公式在Word剪贴板里的真实状态
1.1 复制一条公式,剪贴板里其实同时放着三种格式
很多人以为从Word复制一个公式,剪贴板里就是一张图片。真实情况要复杂得多。Word在复制带公式的段落时,会一次性往剪贴板里写入很多种格式,其中我们最关心的有三种:
text/plain:一段无格式的线性文本,公式结构基本丢失,只适合搜索不适合展示。text/html:包含整个Word文档片段的HTML,公式本身以Office Math Markup Language(OMML)的形式嵌在XML条件注释里。- 图片格式(PNG等):Word为了兼容不支持OMML的目标程序,会自动附带一张渲染后的公式图片。
如果公式是用MathType老版本做的,剪贴板里还会出现OLE对象或者VML图形。也就是说,我们平时“复制→粘贴”这一个动作,背后其实同时传递了好几路数据,关键是目标编辑器愿意读取哪一路。
在很多富文本编辑器里,默认行为是优先读取text/html,但读取之后会做大量“消毒”和“净化”处理。OMML这种生僻的XML结构,会被当成未知标签直接删掉。图片那一路虽然能被保留,但公式从此就变成了一张静态图。这就是“粘贴公式后公式消失/变成图片”的最根本原因。
1.2 图片兜底为什么是饮鸩止渴
功能上线第一版的时候,我们也考虑过最省事的方案:既然Word会附带图片,那就让公式以图片形式粘贴进去,这样用户看起来“诶,公式还在”,似乎需求就完成了。
但这个方案在投研平台里根本站不住脚。研究员在平台里写报告,不只是为了展示公式,而是希望公式能随手改参数、重新计算、参与合规检查、在导出PDF时保证矢量清晰。一张图片公式至少带来四个问题:
- 不可检索:全文搜索“CAPM”时,图片里的公式文字不会被索引到。
- 不可再编辑:用户想改一个β下标,只能删掉整张图片重录。
- 排版失真:图片在缩放、深色模式、打印时都会糊。
- 审计困难:投研报告有合规留痕要求,图片公式无法回溯“原始表达式到底是什么”。
所以问题的本质已经清楚:我们必须拿到公式的语义结构,而不是一张渲染后的快照。这也是选择OMML解析这条路的出发点。
2. 技术选型:从OMML到屏幕上的公式,选哪条渲染链路
2.1 三条候选方案的对比
拿到OMML之后,要让它显示在XHEDITOR的编辑区里,有几条路径可以走。我做了详细对比:
| 方案 | 思路 | 优点 | 主要风险 |
|---|---|---|---|
| A | 保留OMML,浏览器原生渲染 | 零转换、结构完整 | Chrome/Firefox不认OMML,跨浏览器直接失败;XHEDITOR内部也不会认这套标签 |
| B | OMML 转 MathML,用 MathJax 渲染 | 语义结构完整、可检索、可再编辑、无障碍支持好 | 需要一个可靠的转换器;MathJax体积较大,需要按需加载 |
| C | OMML 转 LaTeX,用 KaTeX 渲染 | LaTeX生态成熟、KaTeX轻快 | 转换过程容易丢数学语义;金融公式里大量自定义运算符和上下标嵌套,LaTeX表达力虽强但转换规则很难写全 |
| D | OMML 渲染成 SVG 快照插入 | 表现力绝对一致 | 回到图片方案的老路,只是换了格式 |
最终我选择的是方案B:OMML转MathML,再由MathJax渲染。原因有三个:
第一,MathML和OMML一样,都是结构化的数学标记语言,元素之间存在天然映射关系,转换时不会丢失“分数”“上标”“根式”这类语义。第二,MathJax支持直接从MathML渲染,渲染结果既能复制成其他格式,也能通过辅助技术读取,这对金融报告后续做内容分析和无障碍阅读都有价值。第三,MathJax的SVG输出在导出PDF时是矢量图,打印不会糊。
2.2 为什么金融公式尤其适合MathML
金融投研里的公式,和科研论文里的公式有个很大区别:它们不光是“展示出来好看”,还经常要参与计算。CAPM模型、Black-Scholes定价公式、DCF折现、夏普比率、VaR计算——这些表达式里的每一个变量,在投研系统里可能都对应着某个数据字段。
如果公式只是图片,数据字段的映射无从谈起。而MathML保留了完整的结构信息:<msub>表示下标,<mfrac>表示分数,<mi>标识变量名,<mo>标识运算符。系统可以解析MathML树,把其中的变量抽取出来,跟平台里的指标字段做关联。这是图片、SVG、甚至是LaTeX这些纯展示或纯排版格式都很难做到的。
2.3 一条OMML到底长什么样
为了后面讲转换机制时不抽象,先看一个最简化的例子。Word里输入一个分数表达式a/b,OMML大概是这样的:
<m:oMath> <m:f> <m:num><m:r><m:t>a</m:t></m:r></m:num> <m:den><m:r><m:t>b</m:t></m:r></m:den> </m:f> </m:oMath>对应的MathML则长这样:
<math> <mfrac> <mi>a</mi> <mi>b</mi> </mfrac> </math>从这两个片段能看出,大多数OMML节点都有明确对应的MathML节点,这就是转换能自动化的基础。实际金融公式里更多是上标、下标、求和符号、希腊字母的组合,本质是同一套树结构的不同嵌套。
3. XHEDITOR实战:粘贴拦截、OMML转换、插入与重排
3.1 拦截粘贴并拿到Word的HTML片段
XHEDITOR作为富文本编辑器,允许我们通过事件机制介入粘贴流程。我们第一步要做的是:在编辑器派发paste事件时,截取clipboardData里的text/html,判断是否包含OMML标记。
editor.on('paste', function (event) { const clipboardData = event.clipboardData; if (!clipboardData) return; const html = clipboardData.getData('text/html'); // 只有包含 oMath 才是从 Word/Office 复制过来的公式内容 if (!html || html.indexOf('oMath') === -1) { // 没有公式的普通粘贴,走编辑器默认逻辑 return; } // 有公式内容,我们接管处理 event.preventDefault(); handleWordFormulaPaste(clipboardData); });这里有个细节很多人会踩坑:如果编辑器内部已经实现了自己的粘贴过滤逻辑,而且注册时机比较早,等到我们这个事件处理函数执行时,text/html可能已经被清洗过、OMML已经不见了。所以我们要确保自己注册的粘贴处理器尽可能早,或者直接在编辑器最外层的DOM容器上监听paste事件,抢在编辑器默认逻辑之前拿到原始数据。实测中我是在编写插件时,把监听绑在了编辑器根元素上,这样最可控。
3.2 从HTML里“捞”出OMML节点
拿到HTML字符串后,第一反应可能是用正则去抓<m:oMath>,这是最容易翻车的做法。Word生成的HTML里,OMML可能被包在条件注释里,注释的写法和URL编码在不同Office版本里都不一样,正则很容易漏。
正确做法是用DOMParser把HTML解析成DOM树,然后直接节点查找:
function extractOmathNodes(html) { const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); // 兼容不同Word版本:有些版本用 m:oMath,有些直接把 MathML 塞进去 const oMathNodes = Array.from( doc.getElementsByTagName('m:oMath') ); // 如果文档里本身已包含 MathML,直接使用 const mathNodes = Array.from(doc.getElementsByTagName('math')); return { omath: oMathNodes, mathml: mathNodes, }; }DOMParser会把m:oMath识别成带命名空间前缀的自定义标签,getElementsByTagName('m:oMath')可以正确匹配。注意不要用querySelectorAll('m\\:oMath')这种写法,在部分浏览器里冒号转义和命名空间处理不一致,容易拿空数组。
3.3 OMML转MathML的三条具体路线
拿到OMML节点后,转成MathML有三种做法,我按推荐程度排序:
路线一:使用现成转换库
npm上有一些专门做OMML转MathML的包,原理大多是实现了Office Open XML数学标签到MathML的完整映射表。如果团队能接受引入第三方依赖,这是最节省人力的方式。需要提醒的是,这类库不能闭着眼睛选,要重点核对它对<m:sSub>、<m:sSup>、<m:sSubSup>、<m:eqArr>这些金融公式高频节点的兼容情况,很多轻量库只做了基础转换,遇到稍微复杂的求和上下限就哑火。
路线二:利用Office自带的XSL样式表
Office的安装目录里自带了一份将OMML转换为MathML的XSL样式表(文件名通常类似OMML2MML.XSL)。可以用XSLTProcessor在浏览器端加载这份样式表,对OMML节点做转换。这条路的好处是转换比较完整,坏处是依赖Office安装环境,部署在服务器上不方便。我们最后没有采用它,因为线上环境不是每台机器都有Office。
路线三:自研递归转换器
考虑到金融公式的节点类型其实很有限,而且我们希望转换器能跟投研平台的数据模型对齐,最终选了自研路线。核心思路很直接:遍历OMML节点,按标签名映射到MathML节点,再递归处理子节点。下面是一个精简版本,覆盖了分数、上标、下标、上下标组合、文本、运算符、根式等常见结构:
const OMML_MAPPING = { 'm:f': function (node, convert) { const num = node.getElementsByTagName('m:num')[0]; const den = node.getElementsByTagName('m:den')[0]; const mfrac = createElementNS('mfrac'); mfrac.appendChild(convert(num)); mfrac.appendChild(convert(den)); return mfrac; }, 'm:sSup': function (node, convert) { const base = node.getElementsByTagName('m:e')[0]; const sup = node.getElementsByTagName('m:sup')[0]; const msup = createElementNS('msup'); msup.appendChild(convert(base)); msup.appendChild(convert(sup)); return msup; }, 'm:sSub': function (node, convert) { const base = node.getElementsByTagName('m:e')[0]; const sub = node.getElementsByTagName('m:sub')[0]; const msub = createElementNS('msub'); msub.appendChild(convert(base)); msub.appendChild(convert(sub)); return msub; }, 'm:t': function (node) { const mtext = createElementNS('mtext'); mtext.textContent = node.textContent; return mtext; }, 'm:r': function (node, convert) { // m:r 是一个“运行段”,里面一般只有 m:t return convert(node); }, 'm:rad': function (node, convert) { // 根式:OMML 里 deg 是次数,e 是被开方数 const deg = node.getElementsByTagName('m:deg')[0]; const e = node.getElementsByTagName('m:e')[0]; const msqrt = createElementNS('msqrt'); msqrt.appendChild(convert(e)); return msqrt; } }; function convertOmmlNode(node) { const mapper = OMML_MAPPING[node.tagName.toLowerCase()]; if (mapper) { return mapper(node, convertOmmlNode); } // 默认处理:先把子节点递归转换,包在 mrow 里 const children = Array.from(node.childNodes); const mrow = createElementNS('mrow'); children.forEach((child) => { const converted = convertOmmlNode(child); if (converted) mrow.appendChild(converted); }); return mrow; } function createElementNS(localName) { return document.createElementNS('http://www.w3.org/1998/Math/MathML', localName); }这个版本在项目里跑了一段时间,覆盖了90%的金融公式场景。遇到覆盖不了的特殊结构,比如矩阵<m:m>,再往映射表里加一个分支就行。自研转换器最大的优势是出了问题能快速定位,而不是翻源码去查第三方库哪一层丢节点。
3.4 插入编辑器并让MathJax重排新节点
OMML转成MathML之后,接下来要把MathML作为HTML片段插入XHEDITOR,并让MathJax完成渲染。
async function insertMathMlIntoEditor(mathMlNodes) { const container = document.createElement('div'); mathMlNodes.forEach((node) => { container.appendChild(node.cloneNode(true)); }); // 交给编辑器插入 editor.execCommand('insertHTML', container.innerHTML); // 等待编辑器DOM完成更新 requestAnimationFrame(async () => { await MathJax.typesetPromise([editorContainer]); editor.focus(); }); }这里最容易忽略的问题是MathJax只会在初始化时扫描一遍全文,动态插入的MathML不会自动重排。如果插入后不调用typesetPromise,用户看到的会是满屏的<mfrac>标签源码。
另外,插入MathML的时候要保命名空间。cloneNode默认保留节点的namespaceURI,但如果用innerHTML拼接后再整体插入,MathML节点的命名空间声明可能会丢失。我们测试中最稳妥的做法是:把整个转换结果放进一个设置了xmlns="http://www.w3.org/1998/Math/MathML"的容器里再插入。
3.5 兜底分支:剪贴板里只有图片而没有OMML
现实中不是所有公式都是Office原生公式。有些同事用老版本MathType录入公式,复制的实际上是一段OLE对象加一张图片;还有些场景是从浏览器网页、PDF里复制的公式,剪贴板里压根没有OMML。这时需要一套兜底策略:
- 检测HTML中是否存在
<img>且alt属性带Office/MathType特征; - 检测不到任何公式语义信息时,提示用户改用Word的线性输入模式复制(Word公式工具里选择“线性”),或提供手动插入公式入口;
- 条件允许时,接入自部署的公式OCR服务,对图片公式做识别转成LaTeX,投研数据敏感,OCR识别服务建议内网私有化部署,不能把报告图片发给外部接口。
兜底逻辑的核心原则是“不能静默失败”:如果公式没能以可编辑格式进入正文,必须明确提示用户,而不是悄悄贴一张模糊图片,让用户以为一切正常。
4. 现场排查实录:五个坑,从整篇乱码到能编辑能导出
4.1 坑一:条件注释被编辑器“消毒”,公式永远拿不到
第一版实现接入后,发现一个奇怪现象:同一个Word文档,我在本地Chrome里测试粘贴,公式能正常转换;放到集成环境里测试,公式就消失。后来定位到原因:测试同事用的编辑器版本里,粘贴管道会先对text/html做一轮过滤,把Word条件注释和未知标签提前剥掉了,等我们的粘贴处理器执行时,原始OMML已经不存在了。
排查方式非常简单:在粘贴事件里console.log(clipboardData.getData('text/html')),对比本地环境与远程环境下前400个字符的差异,一眼就看出OMML被提前清洗了。解决方案就是把粘贴监听注册到编辑器根DOM元素上,抢在内部过滤逻辑之前执行,并且用event.preventDefault()把后续默认处理完全挡掉。这里提醒所有踩坑的同行:富文本编辑器的默认粘贴处理往往是“异步+后置”的,依赖编辑器暴露的粘贴事件回调,拿到的不一定是原始剪贴板内容。
4.2 坑二:命名空间丢失导致转换结果全是undefined:m
自研转换器上线后,又出现了一个诡异现象:粘贴简单分数公式时正常,粘贴稍微复杂一点的公式时,页面里出现一堆<undefined:oMath>。仔细检查发现,不是转换器逻辑的问题,而是某些文档里的OMML节点挂在了一个没有显式声明xmlns:m的解析结果下。DOMParser在解析某些Word生成的HTML时,会把命名空间解析成http://schemas.openxmlformats.org/officeDocument/2006/math,而我们在对比tagName时用的是不带命名空间的m:f,一旦前缀解析异常,tagName就会变成undefined:f。
解决方案是不要依赖tagName的字符串前缀,而是用node.localName来取元素的本地名,再配合namespaceURI判断:
function convertOmmlNode(node) { const local = node.localName; if (local === 'f' && node.namespaceURI) { // 说明是 OMML 分数线 } }从那以后,本地名比较就成了转换器的统一规范,再没出现过undefined前缀问题。
4.3 坑三:新节点插入后MathJax没重排,屏幕上全是源码
这个坑在3.4里已经埋了伏笔。第一次联调时,点击粘贴,编辑区里出现了一大段类似<mfrac><mi>a</mi>的源码,视觉效果非常吓人。原因就是MathJax启动后只渲染初始节点,不会监听到动态插入的MathML。
修好之后还遇到一个衍生问题:MathJax.typesetPromise执行期间,用户继续输入会产生光标跳动和渲染闪烁。所以我们把渲染触发点放到了requestAnimationFrame里,并且让新插入的MathML节点包在一个临时容器中,渲染完成后再把容器展开,避免用户在渲染过程中看到半渲染状态。
还有一个配置细节:MathJax初始化时把startup.ready时的自动渲染关掉,改成由代码主动触发typesetPromise。这能避免编辑器初始化阶段和插件插入公式的渲染动作互相竞争。
4.4 坑四:行内公式与独占一行的公式显示混乱
公式显示出来后,接下来是排版问题。金融公式里,像CAPM、B-S这种长的公式,用户在Word里通常是“独占一行、居中排列”;而有些短公式,比如β_i = Cov(R_i,R_m)/Var(R_m),是嵌在正文文字里的。
MathJax对这两种场景有不同输出:独立公式是display模式,行内公式是inline模式。但插入MathML时,如果容器HTML结构没有区分段落模式,MathJax可能把所有公式都当成行内公式,导致独立公式的上下标挤在一起,分式窄得很难看。
解决办法是转换时保留Word里的段落信息:如果OMML节点是从<m:oMathPara>里解析出来的,就给外层包一个带了display="block"语义的容器;如果是<m:oMath>,默认走行内模式。同时配合CSS修正垂直对齐:
mjx-container { line-height: inherit; } mjx-container[display="true"] { margin: 0.6em 0; text-align: center; }实测下来,行内公式的下沉感和独立公式的居中感,通过这些样式能调到和Word里几乎一致。
4.5 坑五:移动端复制进来没有Office标记
移动端测试又是一轮新世界。iOS上从WPS复制一段包含公式的内容,粘贴到XHEDITOR里,text/html里根本不存在m:oMath标签,要么是纯文本,要么是图片。这意味着移动端用户无法走OMML转换链路。
目前的处理策略是分层降级:先检测有无OMML,没有就检测有无带Office特征的图片,再没有就分析纯文本是否像公式。都不匹配时,弹一个明确提示,引导用户用桌面端编辑,或者手动插入。虽然没有做到移动端全链路无缝,但至少不会再把一堆乱码悄悄写进报告正文。
5. 工程化落地:存储、导出与团队协作的隐藏事项
5.1 转换程序不能塞在主线程里
当分析师粘贴的是一整个章节,而章节里有几十条公式时,如果转换全在主线程跑,界面会明显卡顿。我们在后期优化中把解析和转换丢到了Web Worker里,剪贴板HTML传进去,Worker返回转换结果和MathML片段,主线程只负责插入。实际体感是:60条公式的章节,粘贴后首帧没有掉帧,渲染完成后才会看到公式逐个出现。
5.2 存储格式:MathML为主,LaTeX为辅,原OMML留底
内容保存到服务端时,我们设计了三层结构,这是我认为整个方案里最值得参考的部分:
| 层级 | 格式 | 用途 |
|---|---|---|
| 主内容 | MathML + HTML混合 | 编辑回显零成本,MathJax直接渲染 |
| 辅助字段 | LaTeX纯文本 | 全文检索、公式变量抽取、对接计算模块 |
| 归档字段 | 原始OMML XML | 版本审计、未来用官方样式表重新渲染 |
三层分别服务于不同的使用场景。很多团队只存MathML,结果换一个渲染器之后无法回读;只存LaTeX的团队,则面临公式变量无法和编辑器光标实时联动的问题。三层结构虽然冗余,但是对金融投研这种高风险、强合规的场景来说,存储成本换回的是可追溯性。
5.3 老文档里的MathType图片迁移
存量数据是躲不开的坎。历史报告里大量公式已经以图片形式存在了,不可能要求用户重新录入。我们的做法是把迁移拆成两步:
第一步,识别图片特征。MathType生成的图片通常有稳定的alt描述和文件命名特征(比如alt里包含*或MathType字样)。扫描出这些图片后,给内容编辑团队一个批处理工具,可以逐条选择“识别为LaTeX”或“手动录入”。
第二步,接入自部署的公式OCR服务,把识别出的LaTeX转成MathML,替换原有图片。迁移过程不能一次性全量推倒,因为投研报告有合规窗口期,旧版本不能随意篡改。我们选择只对“正在编辑中的新报告”做全量公式转换,对历史归档报告保持原样。
5.4 导出PDF时公式不能糊
研报最终要导出PDF给客户和合规审核,公式清晰度是硬指标。实测中,MathJax的SVG输出在导出时表现最稳定:无论页面缩放还是打印缩放,公式始终保持矢量清晰。我们同时把MathJax渲染模式固定为svg,放弃了默认的HTML-CSS输出,因为后者在分页打印时容易出现行高被裁剪的问题。
导出Word格式是另一个逆向需求:需要把MathML写回OMML。当前方案里,由于我们归档时保留了原始OMML,所以导出Word时直接取归档字段走Office通道即可,不需要再从MathML做一次逆向转换,完美绕开了这个极易出错的环节。
5.5 给团队的一句话建议
如果团队里同时有前端和内容运营同学,公式粘贴问题的优先级排序很容易被误判。从表面看,这只是编辑器的一个小功能;从实际投入看,它横跨剪贴板协议、XML命名空间、数学标记语言、渲染引擎和存储架构五个领域。我最后的建议是:不要一切换到“转换代码”环节,而是先从真实业务文档里拿出3份含公式的样稿,对着剪贴板原始HTML分析一遍结构,对“什么公式必须可编辑、什么公式可以图片兜底”达成共识,再动手写代码。这样整个项目的推进会顺畅得多,也不容易出现“功能上线一个月后才发现公式没法全文搜索”这种返工事故。