教育信息化推进到今天公式输入仍然是最容易让老师和学生都崩溃的一环。我们这边使用的XHEDITOR在线作业系统学生交作业的入口是一个基于contenteditable的富文本编辑器数学、物理、化学老师又都是Word的老用户“我在Word里排好的公式为什么粘贴到网页作业里就变成乱码或者一张模糊的图”——这个问题几乎每周都会被重新问一遍。这篇文章把我从零搭起来的一条“Word公式粘贴到XHEDITOR”完整链路拆开讲清楚包括剪贴板里到底发生了什么、怎么识别OMML和MathType、为什么最终选择LaTeX作为中间语言、图片兜底怎么设计以及上线之后踩过的几个具体坑。如果你手头也有类似的在线作业、答题或者评测系统这条链路应该是可以直接借鉴的。1. 粘贴公式乱象的根源剪贴板到底给了你什么很多人第一反应是“Word复制网页粘贴不就一个CtrlC再CtrlV的事吗”。但公式和普通文字最大的区别在于它不是一个扁平字符串而是一棵有嵌套关系的结构树。分数有分子和分母根式有被开方数和次数求和符号上下有边界。这些结构在复制粘贴的过程中会被剪贴板转换并丢弃大量信息乱码和丢字就是这样来的。要解决问题第一步不是急着写转换函数而是先搞清楚一次普通的Word复制剪贴板里到底放了几样东西。1.1 Word公式的三张“面孔”以我自己的实践来看从Word公式区域复制内容粘贴到浏览器时剪贴板里会出现三种形态的公式需要区别对待。第一类是Word 2010以后自带的原生公式专业名称叫OMMLOffice Math Markup Language。当你CtrlC一个用“插入-公式”创建的对象时剪贴板的text/html数据里会带有一段以m:oMath或m:oMathPara开头、带xmlns:m命名空间的XML。这段XML记录了公式的完整结构谁在分子上、谁在分母上、谁是上标、谁是下标、括号类型是什么。这是质量最高、最理想的输入源所有结构信息都在转换出来的公式理论上可以和原公式完全一致。第二类是MathType以及“公式编辑器3.0”这类插件生成的OLE对象。这类公式在Word里显示得很正常但它们本质上是嵌入文档的二进制对象复制到剪贴板时text/html中呈现出的是一个object标签加上一条clsid记录通常还会伴随一个Word渲染好的预览图片。浏览器并没有能力直接读取OLE二进制内容所以这部分是后续方案里最难啃但也最需要做好预案的部分。第三类是已经被扁平化成了纯图片的公式。比如某些版本Word在兼容模式下复制、某些第三方输入法或文档转换工具产生的数据剪贴板里只有一张png或者位图没有任何结构信息。这种数据做不到“编辑还原”只能走图片上传端口。我在接入XHEDITOR时给三类数据定了明确的处理优先级有OMML优先转换OMML检测到OLE对象就尝试额外解析实在拿不到结构化信息就兜底用图片。这个优先级顺序非常重要如果反过来先贪图简单处理图片那老师的公式就永远停留在“能看不能用”的状态一旦作业要批改、要引用、要重新编辑后面全都是麻烦。1.2 剪贴板的数据格式决定了处理顺序浏览器里的paste事件能拿到的数据跟我们平常在系统里看到的“复制内容”不太一样它其实是多份数据格式的集合每一份对应一种MIME类型。比较常见的有text/plain纯文本、text/html带格式HTML、text/rtf富文本格式、image/png、image/bmp以及files列表中的文件对象。Chrome和Edge对text/html支持得比较好Word复制过来的HTML里就藏着OMML和相关注释。Firefox大体也能拿到同样的数据但Word位图有时候会落到files里格式判断要写得更宽容一点。Safari则是另一个故事从Word粘贴时text/html有可能被剥离得很严重几乎只剩纯文本和图片这种情况下OMML转换链路直接失效只能老实走兜底图片。也就是说代码里不要只写“如果拿得到text/html就处理公式否则忽略”而是应该按text/html - image - text的顺序逐级降级。每次粘贴都先扫描一遍剪贴板里到底有哪几种可用数据再决定走哪条链路。我建议把判断逻辑单独封装成一个parsePastePayload函数返回格式化后的数据结构后续的编辑器组件、上传模块、转换服务各自取需要的数据互不耦合。顺便提一个容易忽略的点这里说的text/html是浏览器通过clipboardData.getData(text/html)拿到的不是你监听document.onpaste之后对e.clipboardData直接遍历就能看到的全部内容。浏览器出于安全限制并不会把剪贴板里的自定义格式全部暴露给JavaScript我们能稳定拿到的就是上面那几类。所以不要指望在浏览器端直接读出Word OLE对象的二进制流不现实。2. 拦截、识别与定位如何稳定拿到OMML和OLE的壳搞清楚剪贴板里有什么之后下一步就是写拦截逻辑。这里有个很关键的设计点不要在所有可编辑区域都做全局paste监听而是只针对XHEDITOR里真正承载公式输入的内容区。否则用户在题目描述框、标题框里随便粘贴一些带格式内容也会被误判成“公式粘贴”徒增没必要的转换开销和后台请求。2.1 从paste事件入手先看能拿到什么在实际项目中我是这样搭骨架的editor.addEventListener(paste, async (e) { const clipboardData e.clipboardData; const html clipboardData.getData(text/html); const plain clipboardData.getData(text/plain); const files Array.from(clipboardData.files || []); // 优先处理含OMML的HTML const ommlBlocks extractOMMLFromHtml(html); if (ommlBlocks.length 0) { e.preventDefault(); const latexList []; for (const block of ommlBlocks) { const latex await ommlToLatex(block); if (latex) latexList.push(latex); } insertLatexIntoEditor(latexList); return; } // 其次处理MathType OLE对象的壳 const oleDetected detectOleFromHtml(html); if (oleDetected) { e.preventDefault(); const imageFile files.find(f f.type.startsWith(image/)); if (imageFile) { const url await uploadImage(imageFile); insertImageIntoEditor(url); notifyUser(检测到公式插件对象已以图片形式插入如需编辑请改用Word自带公式); } return; } // 最后才让浏览器默认行为接管 });这段代码最核心的动作是e.preventDefault()要放在处理入口而不是放在最前面。有些同学喜欢不管三七二十一先阻止默认粘贴后面再慢慢判断结果普通文本粘贴也被拦下来用户体验非常差。我自己的习惯是只有当确认剪贴板内容命中公式相关格式时才阻止默认行为否则一律放行让浏览器该怎么贴就怎么贴。2.2 从HTML里准确捞出OMMLOMML在text/html里的位置很隐蔽它不是直接躺在一个div里而是藏在条件注释中。大概长这样!--[if gte mso 9]xml m:oMath xmlns:mhttp://schemas.openxmlformats.org/officeDocument/2006/math m:f m:numm:rm:ta/m:t/m:r/m:num m:denm:rm:tb/m:t/m:r/m:den /m:f /m:oMath /xml![endif]--所以第一步是用一个正则把条件注释块整体捞出来再判断里面是否有m:oMath或m:oMathPara。我实测中发现一个问题有些在线文档系统或者浏览器插件会干预复制行为导致条件注释被改写、丢失所以正则捞不到时不要立即放弃还要再尝试直接在原始HTML里搜索m:oMath[\s\S]*?/m:oMath这样的开放标签对。function extractOMMLFromHtml(html) { if (!html) return []; const results []; // 优先条件注释块 const commentRegex /!--\[if gte mso 9\](xml[\s\S]*?\/xml)!\[endif\]--/g; let match; while ((match commentRegex.exec(html)) ! null) { if (match[1].includes(m:oMath)) { results.push(match[1]); } } // 找不到再退而求其次直接扫m:oMath标签 if (results.length 0) { const directRegex /m:oMath[\s\S]*?\/m:oMath|m:oMathPara[\s\S]*?\/m:oMathPara/g; while ((match directRegex.exec(html)) ! null) { results.push(match[0]); } } return results; }这里有个细节要提醒如果一个页面里同时粘贴了多个公式data里可能包含多个m:oMath片段正则要用g标志循环匹配不能只取第一个。我最初只用了一个非全局正则导致老师粘贴两个公式时只进来第一个测试同事还以为是公式丢了排查了半天才发现是匹配没写对。2.3 OLE对象能识别到壳但拿不到肉MathType这类OLE对象在text/html中的典型表现是object标签里带一串很长很长的classid。大部分情况下classid会包含0002E556或者0002E552这样的标识。我用一个很朴素的判断函数function detectOleFromHtml(html) { if (!html) return false; return /object[^]*classid\s*\s*[][^]*0002E55/i.test(html) || /object[^]*classid\s*\s*[][^]*0002E52/i.test(html); }但识别出来“壳”只解决了一半问题。难点在于浏览器剪贴板并不会把OLE对象的原始二进制数据交给你这个对象本质上是一个在Windows系统里才能解析的复合文档。很多场景下object标签旁边会有一个embed子节点里面可能带一个预览图的引用地址也可能什么都没带真正可用的只有Word顺带渲染出来的那一位图。因此我的实践策略是先识别到OLE能通过embed里的信息解析出可用内容更好解析不出来就明确告知用户“此公式将以图片形式插入”。没必要为了追求完美而把整条粘贴链路卡死。后面如果真的要彻底解析MathType有两个方向可以参考一是服务端在Windows环境挂Office组件做二次转换二是引入专业的MTEF解析库但这两者的工程量都比OMML转LaTeX高出一个数量级需要自己权衡。3. 转换链设计为什么最终选择OMML到LaTeX以及怎么转识别只是开胃菜真正的核心是把识别到的OMML结构翻译成编辑器能渲染的东西。3.1 为什么绕道LaTeX而不是保留MathML刚开始做过一次小的技术选型既然OMML是XML能不能直接用MathML答案是否定的。浏览器并不原生支持渲染OMML而且MathML在不同浏览器里的兼容性差异非常大。XHEDITOR内部目前用的公式渲染方案是MathJax和KaTeX这一套它们都不认OMML输入但都支持LaTeX语法。LaTeX作为中间语言有一个很大的好处它本身是一套纯文本标准几乎不会丢信息不管是系统内存储、接口传输、还是第三方系统对接都不会出现“粘贴完再复制就全乱”的情况。如果将来想把老师作业里的公式导出来比如生成PDF、排版试卷、导入题库LaTeX都是最通用的表示方式。有人可能会问那为什么不干脆把OMML原样存到数据库里只在渲染时转换我试过效果不理想。因为后续老师还要编辑公式、要把公式提取成题目、要和成绩数据库做关联一个OMML XML对象在各个系统之间的传输成本远比一行LaTeX字符串高。而且编辑器组件本身只认LaTeX存OMML反而多此一举。3.2 常用OMML元素到LaTeX的映射表OMML本身有一整套复杂的样式体系但日常作业里用到的核心结构其实不超过十几种。我把频率最高的映射关系整理成了一张表转换器就是按这张表逐节点翻译OMML元素作用LaTeX输出示例m:f/m:num/m:den分数\frac{分子}{分母}m:sSup/m:sSub/m:sSubSup上标、下标、组合式x^2、x_1、\sum_{i1}^{n}m:rad/m:deg/m:e根式\sqrt{...}、\sqrt[3]{...}m:d/m:e定界符括号\left( ... \right)m:nary/m:sub/m:sup大型运算符求和、积分\sum_{...}^{...}、\int_{...}^{...}m:m/m:mr/m:mc矩阵\begin{matrix} ... \end{matrix}m:r/m:t普通文本run直接转义输出文本以最基础的分数为例。OMML里分子在m:num、分母在m:den转换器只需要把两个子节点的内容分别递归转换然后拼成\frac{}{}即可。根式稍微复杂一点根指数存在m:deg里被开方数存在m:e里有m:deg就走\sqrt[指数]{被开方数}没有就走\sqrt{被开方数}。这里有一个必须注意的点m:r内部的文本不能直接原样输出。LaTeX对、%、$、#、_、{}这六个字符有特殊语义Word里的普通文本如果包含这些字符需要通过转义函数处理后再拼进LaTeX字符串否则渲染出来的公式会莫名其妙断掉。我早期漏掉了%的转义结果公式里只要出现百分号整段渲染全废。3.3 简化版遍历转换的实现思路下面是我在项目里用的一个简化版递归遍历函数代码只保留了主干逻辑生产环境用的话还需要补充大量样式属性和容错分支const MATH_NS http://schemas.openxmlformats.org/officeDocument/2006/math; function ommlToLatex(node) { const tag node.localName ? node.localName.split(:).pop() : ; switch (tag) { case oMath: case oMathPara: return Array.from(node.children).map(ommlToLatex).join(); case r: { const t node.getElementsByTagNameNS(MATH_NS, t)[0]; const text t ? t.textContent : ; return escapeLatex(text); } case f: return \\frac{ ommlToLatex(node.getElementsByTagNameNS(MATH_NS, num)[0]) }{ ommlToLatex(node.getElementsByTagNameNS(MATH_NS, den)[0]) }; case sSup: return { ommlToLatex(node.getElementsByTagNameNS(MATH_NS, e)[0]) }^{ ommlToLatex(node.getElementsByTagNameNS(MATH_NS, sup)[0]) }; case sSub: return { ommlToLatex(node.getElementsByTagNameNS(MATH_NS, e)[0]) }_{ ommlToLatex(node.getElementsByTagNameNS(MATH_NS, sub)[0]) }; case rad: { const deg node.getElementsByTagNameNS(MATH_NS, deg)[0]; const base node.getElementsByTagNameNS(MATH_NS, e)[0]; if (deg) return \\sqrt[ ommlToLatex(deg) ]{ ommlToLatex(base) }; return \\sqrt{ ommlToLatex(base) }; } default: return Array.from(node.children).map(ommlToLatex).join(); } } function escapeLatex(text) { return text .replace(/\\/g, \\backslash ) .replace(/\{/g, \\{) .replace(/\}/g, \\}) .replace(/\$/g, \\$) .replace(//g, \\) .replace(/#/g, \\#) .replace(/_/g, \\_) .replace(/%/g, \\%) .replace(/\^/g, \\textasciicircum{}); }这段代码放在哪里执行我的建议是放服务端而不是浏览器端。原因主要有三点一是浏览器环境下DOMParser对XML命名空间的处理在不同浏览器里有差异服务端用XML DOM解析更稳定二是复杂公式的递归转换会消耗CPU放在前端会让低端设备在粘贴的瞬间卡顿三是服务端转换逻辑更容易做缓存、日志和后续算法升级。实际架构上前端只做“捞OMML片段”然后把OMML字符串通过接口发给后端后端完成转换把LaTeX结果返回给前端插入编辑器。3.4 MathType的务实解法前面说过了MathType OLE对象在浏览器端基本拿不到有效二进制。所以在我的方案里MathType被降级为“优先图片处理”。代码逻辑是在粘贴拦截时检测到object标签后直接从e.clipboardData.files里找图片文件上传并且在编辑器里插入图片的同时弹出一行小提示“如果希望公式可编辑请使用Word 2010以上版本自带的公式编辑功能。”这个提示不是给用户添堵而是教育信息化场景里一个非常现实的妥协。老师们用MathType已经很多年突然让他们改掉习惯很难但对他们来说“能交作业、能被批改、卷面不乱”是底线需求图片公式完全能满足。真正对可编辑性有强需求的是题库运维和试卷复用这一部分用户规模小让他们切换到Word原生公式的迁移成本是可以接受的。如果你非要在MathType上做完整解析也不是不可能但要做好心理准备MTEF是一套二进制协议你需要逐字节解析字体表、字符映射、样式参数整个工程量比OMML正则转换大不少。我在技术调研阶段看到有些商业题库系统是直接调用Windows端Office组件来转换的部署在Windows服务器上每来一个请求就打开一个文档进程处理性能和稳定性都很难调。对绝大多数教育信息化项目来说这条链路不划算。4. 图片兜底不是偷懒是整条链路的第二道保险最开始我对图片兜底是很抗拒的觉得公式变成图片等于放弃了可编辑性。但真实上线之后发现图片兜底不但不是偷懒反而是整条链路里最不可或缺的一道保险。4.1 兜底图的来源与质量把控图片兜底的数据来源主要有两个。一个是clipboardData.files里的位图文件这是Word在复制公式时顺带生成的预览图通常质量还可以另一个是从HTML段落里提取的img标签src这个可能是base64编码也可能是相对路径质量和前一个来源差别很大。我建议优先用files里的图片因为浏览器对它的处理相对干净而且可以直接构造FormData上传。如果是base64的数据要先把字符串转成Blob再走统一上传通道。对图片质量做一个最基本的校验宽高至少大于100像素文件大小不超过2MB超过就做压缩。公式图如果太小老师批改时放大就会糊成一块所以质量下限必须卡住。上传之后的图片地址会作为src插入编辑器但这张图本身脱离了原始公式结构后续如果想重新编辑公式只能删掉重打。因此在编辑器层面我会把这类图片打上一个自定义标记比如>
阅读完成 · 觉得有帮助?