ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Word公式在UEditor中乱码的根治方案:OMML提取与MathJax渲染

Word公式在UEditor中乱码的根治方案:OMML提取与MathJax渲染 军工项目里的ueditor说来都是泪。内网系统还在用1.4.3的老版本从Word复制一篇带公式的技术文档到编辑框里公式要么变成一串天书要么变成小方框要么直接消失。这个问题我前后折腾了快两周客户现场催得急最后才把整条链路理顺。今天把这套方案完整写出来给还在跟ueditor公式乱码死磕的朋友一个参考。先给结论Word公式在ueditor里乱码本质是公式的原始结构OMML在粘贴过程中丢了只剩下一堆Unicode残片。要根治必须做三件事——粘贴时拦截剪贴板里的OMML数据、后端把OMML转成MathML、前端用MathJax渲染成可视化公式。这套方案我在几个军工内网项目里落地过不依赖外网、不依赖第三方在线API完全在客户机房内部跑通。1. 乱象分析Word公式在ueditor里到底是怎么变成乱码的1.1 三种最常见的“乱码”现场先说客户报障时最典型的三种情况你一听描述就能判断出问题出在哪个环节。第一种叫“上下标小字符满天飞”。公式从Word复制过去后Sigma、积分号、上下标全部散架变成一堆缩小的字符比如“ᵢ₊₁”“ₙ²”这种。这种看起来像是编码问题其实不是——这是Word把公式里的Unicode数学字体字符原样粘贴进了HTML但公式的“结构”全丢了分数和根号原本是有层级关系的现在只剩字符堆叠。第二种是“灰色方块方框阵”。粘贴后显示一列灰色小方框每个框里有个叉号或者问号这是典型的OLE对象丢失。老版本Word的公式编辑器Microsoft Equation生成的是OLE对象浏览器不认这个二进制对象ueditor过滤时直接把它踢了剩下一堆无效引用。第三种最坑“内容整体消失”。你从Word复制一整段文字过来了公式位置空了一大块好像从来没存在过。这种情况是因为公式被Word放进了剪贴板的一个独立的XML片段里ueditor默认粘贴逻辑里根本没有读取这个片段的代码所以公式自然就被丢弃了。搞清楚这三类现象你再看解决方案思路就清晰了——所有问题都指向同一个根源粘贴时公式的原始结构数据没有进入ueditor内容区。1.2 为什么不能拿“Word另存网页”的思路来做有一种想法很朴素既然Word公式乱码那就让用户先在Word里“另存为网页”把公式转成图片或者HTML再复制粘贴。这个方案我在早期项目里试过实际跑不通。Word另存网页有两种结果。一种是把公式转成图片但图片引用的是本地磁盘路径比如file:///C:/Users/xxx/AppData/Local/Temp/xxx.png粘贴到浏览器后图片全裂。另一种是生成VML矢量标记这玩意儿只在IE时代有效现代浏览器和国产浏览器基本不渲染。再加上军工内网电脑普遍锁了保存路径、禁止写入临时目录另存为网页这个操作在客户现场根本走不通。还有一个更残酷的现实你不能要求每个用户都会操作。客户那边的工程师打开Word复制、粘贴就完事了。如果你在操作流程上加任何一步“先把公式转成XX格式”半年后工单还是会原样涌过来。1.3 剪贴板里的秘密Word到底给了浏览器什么要解决问题得先知道Word在我们点击“复制”的那一刻往系统剪贴板里塞了什么。你用Word复制一段带公式的内容时剪贴板里实际包含好几种格式的数据纯文本、RTF格式文本、HTML片段、图片格式还有一份OMMLOffice Math Markup Language格式的XML数据。浏览器拿到的是text/html这部分就是你粘贴时看到的HTML。浏览器能拿到的HTML片段大致长这样html xmlns:vurn:schemas-microsoft-com:vml xmlns:ourn:schemas-microsoft-com:office:office xmlns:mhttp://schemas.openxmlformats.org/officeDocument/2006/math xmlnshttp://www.w3.org/TR/REC-html40 head meta nameProgId contentWord.Document meta nameGenerator contentMicrosoft Word 15 /head body p classMsoNormal这是一个公式/p m:oMath m:rm:tf/m:t/m:r m:rm:t(x)/m:t/m:r m:d m:dPr/m:dPr m:e m:rm:t /m:t/m:r m:f m:numm:rm:t1/m:t/m:r/m:num m:denm:rm:t2/m:t/m:r/m:den /m:f /m:e /m:d /m:oMath /body /html注意看这段HTML里的m:oMath节点。这就是公式的结构化数据它清清楚楚地描述了分数、上下标、根号等数学结构。ueditor要么把包含m:前缀的标签判定为非法标签过滤掉要么在粘贴时只提取了纯文本部分结构信息就此人间蒸发。知道这个数据结构之后方案就明确了我们要做的不是“修乱码”而是在粘贴过程中把m:oMath节点完整提取出来走一条独立的公式渲染链路。2. 思路定调用“OMML提取 XSLT转换 MathJax渲染”打通公式链路2.1 为什么选OMML作为中间格式你可能想问为什么不直接从剪贴板拿LaTeX或者MathML原因很简单——Word复制时根本不提供这些格式。剪贴板里能拿到的公式结构化数据有且只有OMML这一种。这是微软在Office 2007之后为公式打造的XML语言只要用户在Word里用系统自带公式编辑器或者MathType写入后转换过的公式写的内容复制时都会带上OMML数据。OMML到MathML的转换长期被忽略其实是一个很成熟的技术路线。微软自己就提供了一份XSLT模板文件OMML2MML.XSL专门负责把OMML转换成MathML。有了MathML前端就可以用MathJax统一渲染浏览器不用区分公式是从Word来的还是从其他编辑器来的。中间的转换链就是Word复制 - 剪贴板HTML含OMML节点 - 提取OMML - XSLT转换 - MathML - MathJax渲染 - 插入ueditor2.2 军工内网环境下的方案约束这套链路在普通公网项目里好做但在军工内网环境里要额外考虑三个硬约束。第一是离线部署。内网机房不能访问外网CDN不能用MathJax的JS和字体文件必须全部打包成静态资源部署到本地。我一开始做的时候直接引了CDN的MathJax到了客户现场一刷新页面公式全白因为内网根本抓不了外网资源。第二是涉密管控。系统里处理的文档可能涉及敏感内容字节不能出内网。所以公式转换必须由服务器本地完成任何依赖第三方在线公式识别API的方案在立项评审阶段就会被安全部门毙掉。我们在技术方案里要明确写“全部处理在内网服务器完成无外网调用”有的客户还会要求做日志脱敏转换过程中不能打印公式原文。第三是软件环境杂。军工单位的终端五花八门Windows 7配IE11、Windows 10配360安全浏览器、国产麒麟系统配奇安信浏览器都很常见。MathJax版本选型必须考虑这些老内核浏览器的兼容性后面的部署章节我会细说。2.3 方案对比前端转换还是后端转换OMML转MathML这件事理论上可以写纯JavaScript在前端完成也有别人做好的开源库比如OMML2TeX那种。但我在项目里最终没有选前端转换原因有两个。第一个是浏览器兼容性。老内核浏览器尤其是IE对XML解析、XSLT处理的支持差异很大。IE的XSLT接口是ActiveXObject(MSXML2.DOMDocument)Chrome用的是DOMParser和XSLTProcessor为了兼容这些你得写一堆分支代码测试成本极高。后端用Java的统一接口处理浏览器只负责上传OMML字符串、接收MathML字符串兼容问题只在后端存在一次。第二个是性能和数据安全。后端可以预编译XSLT模板避免每次转换都重新加载解析XSLT文件前端转换做不了这种优化。后端还能在转换前做数据校验避免恶意OMML进入系统。虽然军工内网不太担心XSS注入但统一入口总归更可控。3. 实操实现前端粘贴拦截与公式提取3.1 用捕获阶段监听粘贴事件绕过ueditor默认过滤ueditor的粘贴逻辑在底层源码里写死了默认把剪贴板HTML塞进编辑器并走一遍filterTxtRules白名单过滤。我们没法优雅地在它处理完之后拿回OMML节点因为节点已经被过滤掉了。所以策略是在document的捕获阶段监听paste事件先于ueditor拿到粘贴内容。如果检测到内容包含OMML节点就阻止默认粘贴行为自己走公式处理流程如果不包含公式就放行让ueditor按原逻辑处理。这样对正常粘贴操作完全无感只有带公式的文档才走新链路。核心代码如下document.addEventListener(paste, function (e) { var editor window.currentEditor; // 提前拿到编辑器实例 if (!editor || !editor.body) return; // 判断粘贴目标是否在编辑器内 var target e.target; var isInEditor editor.body.contains(target) || editor.container.contains(target); if (!isInEditor) return; var html getClipboardHtml(e); // 没有公式节点的内容交给ueditor默认处理 if (html.indexOf(oMath) -1 html.indexOf(oMathPara) -1) { return; } // 有公式阻止默认粘贴走自己的逻辑 e.preventDefault(); e.stopPropagation(); handleWordPaste(editor, html); }, true);这里有两个细节要注意。第一个是window.currentEditor怎么来的在编辑器初始化完成后赋值var editor UE.getEditor(editorId); window.currentEditor editor;第二个是粘贴事件触发时ueditor内部可能已经在监听同一个事件我们使用捕获阶段第三个参数传true可以保证先于ueditor处理及时调用stopPropagation拦截掉后续冒泡阶段的事件。3.2 从剪贴板HTML中提取OMML节点拿到带公式的HTML字符串后下一步是把里面的m:oMath节点提取出来。注意一个Word文档里可能有多个公式所以提取结果是一个数组。实现代码如下function extractOMMLFromHtml(html) { var results []; var parser new DOMParser(); var doc parser.parseFromString(html, text/html); // 从Word粘贴的HTML里OMML节点可能是 m:oMath 或 m:oMathPara // 注意标签名里的冒号在querySelectorAll里需要转义 var mathNodes doc.querySelectorAll(m\\:oMath, m\\:oMathPara); for (var i 0; i mathNodes.length; i) { var serializer new XMLSerializer(); results.push(serializer.serializeToString(mathNodes[i])); } return results; }这里有一个实际踩过的坑querySelectorAll的转义符问题。如果你直接写querySelectorAll(m:oMath)浏览器会把冒号当作CSS伪类分隔符要么报错要么匹配不到。必须用双反斜杠m\\:oMath或者用getElementsByTagNameNS(http://schemas.openxmlformats.org/officeDocument/2006/math, oMath)这种带命名空间的方式但后者在HTML解析环境下命名空间不一定保留实测不如querySelectorAll稳定。提取出来的OMML是一串XML比如第一节里那个m:oMath示例。把这个字符串原样发给后端转换接口。3.3 把OMML传给后端做转换Java接口示例后端我用Java实现了一个最简接口接收OMML字符串返回MathML字符串。用Spring MVC就是一句话的事RestController RequestMapping(/formula) public class FormulaConvertController { PostMapping(/convert) public Result convert(RequestBody ConvertRequest request) { try { String omml request.getOmml(); if (omml null || omml.trim().isEmpty()) { return Result.error(OMML内容为空); } String mathml ommlConverter.convert(omml); return Result.success(mathml); } catch (Exception e) { // 不打印公式原文避免敏感信息进日志 log.error(OMML转换失败, e); return Result.error(公式转换失败); } } }注意日志这一点很关键军工项目安全审查会看这个。日志里只输出异常堆栈不拼接OMML原文防止公式里的敏感内容出现在日志文件里被审计系统扫出来。3.4 插入渲染结果并处理ueditor白名单冲突后端返回MathML后前端用MathJax把MathML渲染成可视化的HTML再插到编辑器里。这里有个取舍是把MathML标签直接插入还是把渲染后的HTML插入我的经验是插入渲染后的HTML。因为ueditor的内容过滤规则对MathML里的math、mi、mo这些标签一窍不通要么过滤掉要么留着但显示成一堆标签源码。直接把MathJax渲染后的HTML插入ueditor把它当作普通富文本内容完全不会动它省去配置白名单的麻烦。实现如下async function handleWordPaste(editor, html) { var ommlList extractOMMLFromHtml(html); // 提取公式后把公式的位置替换成占位符处理剩下的普通文本 var textContent html.replace(/m:oMath[^]*[\s\S]*?\/m:oMath/g, 【公式占位】); // 这里可以继续对textContent做ueditor样式清理此处省略 var insertHtml textContent; var needInsertHolders []; for (var i 0; i ommlList.length; i) { var omml ommlList[i]; var mathml await sendToBackend(omml); // POST /formula/convert var formulaHtml await renderMathMLToHTML(mathml); // 用渲染结果替换第一个占位符 insertHtml insertHtml.replace(【公式占位】, formulaHtml); } // 最后插回编辑器 insertHtml cleanUpWordSpacing(insertHtml); editor.execCommand(insertHtml, insertHtml); }MathJax渲染部分function renderMathMLToHTML(mathml) { var holder document.createElement(div); holder.innerHTML mathml; return MathJax.typesetPromise([holder]).then(function () { return holder.innerHTML; }); }3.5 兜底策略识别不到公式时的降级处理有一种情况你必须考虑用户用的是旧版Word 2003的公式编辑器复制到剪贴板时根本不含OMML只有OLE对象。这种情况下OMML节点提取不到转换链路走不下去内容会丢。我们的兜底方案是检测到剪贴板HTML里有o:OLEObject或者v:shape这种VML节点时给用户弹一个提示框建议使用Word 2010以上的版本编辑公式或者导出文档为docx后再复制。虽然不够优雅但至少不会“无声无息”丢内容。还有一种更简单的场景有些用户直接把公式截图保存为图片再粘贴这种情况ueditor默认就能处理因为图片粘贴本身是正常流程。4. 后端转换实现OMML到MathML的关键细节4.1 找到OMML2MML.XSL并配置后端转换的核心依赖是一个XSLT文件OMML2MML.XSL。这个文件微软随Office一起分发如果你电脑上装了Office通常在以下路径能找到C:\Program Files\Microsoft Office\root\Office16\OMML2MML.XSL不同Office版本路径里Office16可能不一样Office 2013是Office15Office 2010是Office14。找到后把这个文件复制到项目的静态资源目录或WEB-INF/classes下面不依赖外网、不需要许可内网项目直接用。如果项目组里没人有Windows环境也可以去开源仓库找这份XSLT的镜像内容是一样的。但要留意一下文件的编码有些镜像版本是UTF-8有些是UTF-16Java加载时可能因编码问题报错。4.2 Java代码实现和性能优化后端转换的Java实现标准JDK自带XSLT引擎就够用不需要额外引三方包。import javax.xml.transform.*; import javax.xml.transform.stream.StreamResult; import javax.xml.transform.stream.StreamSource; import java.io.StringReader; import java.io.StringWriter; public class OmmlConverter { private volatile Templates templates; public String convert(String omml) throws TransformerException { Transformer transformer getTemplates().newTransformer(); StringWriter writer new StringWriter(); Source source new StreamSource(new StringReader(omml)); transformer.transform(source, new StreamResult(writer)); return writer.toString(); } private Templates getTemplates() throws TransformerConfigurationException { if (templates null) { synchronized (this) { if (templates null) { // xsl文件的路径部署时放到classes目录下 InputStream xslStream getClass().getClassLoader() .getResourceAsStream(xsl/OMML2MML.XSL); templates TransformerFactory.newInstance() .newTemplates(new StreamSource(xslStream)); } } } return templates; } }性能上有一个必须注意的点Templates对象要复用不要每次转换都new一个TransformerFactory和Templates。XSLT模板的加载和解析开销很大在高频并发场景下每次重新加载会导致CPU飙高和Full GC。我一开始没有复用Templates压测两百个并发就把老年代堆挤爆了后来改成懒加载单例才稳定。另外XSLT转换默认会带出大量命名空间声明导致转换结果里一串xmlns:m...这种噪声。这个不影响MathJax渲染但会撑大库里的内容体积。如果在意可以在转换后做一次命名空间清理或者接受这种冗余反正量不大。4.3 转换结果的校验与异常处理OMML转换失败的情况比想象中多主要出现在公式嵌套复杂、包含特殊符号比如带圈数字、箭头时XSLT本身可能跑不过去。转换接口必须做好异常兜底。我的做法是转换后检查MathML的特征标签比如math是否存在。如果转换结果是空串或者没有math节点直接返回错误信息。前端的降级逻辑是某个公式转换失败时至少保留一个占位符和错误提示不能把整个粘贴内容扔了。实践中几百个Word文档转换下来失败率在百分之几的量级大部分失败是因为XSLT无法识别OMML中的扩展数学符号这种场景下可以提示用户将公式转为图片格式后粘贴属于可接受的降级。5. 军工环境部署适配离线、杀软、国产浏览器的应对5.1 MathJax离线资源与字体瘦身MathJax默认从CDN加载内网环境必须把资源全量下载后放本地。完整MathJax包大概几十MB其中绝大多数是字体文件。对于军工内网这种网络环境部署一个几十MB的静态资源包不是什么大问题但如果你用的是老掉牙的服务器或者客户要求资源包尽量小而精可以做个瘦身。做法是在MathJax配置里指定只使用SVG输出不让它加载字体文件。MathJax 3支持SVG渲染引擎渲染出来的公式是矢量图形不依赖字体体积骤减而且打印效果更好。配置如下window.MathJax { loader: {load: [input/mml, output/svg]}, svg: {fontCache: local} };用SVG输出还有一个额外的好处公式变成图形后ueditor的内容过滤完全不会干扰它。缺点是公式再次编辑会很麻烦因为存进库里的内容已经从MathML变成SVG标签了。如果客户明确要求公式可以二次编辑那还是要用HTML-CSS输出加字体文件这两种方案看需求选。5.2 老浏览器与国产化浏览器的兼容处理军工内网浏览器环境乱我分两种情况说。一种是Chromium内核的国产浏览器比如360安全浏览器、奇安信、红莲花这类浏览器对ES6、MathJax 3支持都还好问题不大按标准方案部署即可。另一种是IE11甚至IE9的老环境MathJax 3直接不兼容只能用MathJax 2.7.9。MathJax 2.7.9对MML渲染同样支持只是API不一样渲染方法从MathJax.Hub.Queue([Typeset, MathJax.Hub, container])改成这个。如果你要兼容IE需要把整个技术路线里依赖ES6 Promise的代码改掉。我的建议是开发前先跟客户确认浏览器基线如果超过20%的终端还在用IE11就直接用MathJax 2.7.9省得后面返工。还有一点军工项目经常装了安全管理软件粘贴板权限会被锁。从Word复制公式后如果粘贴事件拿不到clipboardData或者getData返回空串多半是被安全管控策略拦了。这种情况没有太好的绕过办法只能协调终端安全策略放行编辑器的剪贴板访问把这个作为部署前置条件之一。5.3 涉密数据安全要求整个公式转换链路中安全要求最集中的几个点第一转换接口必须在服务器内网部署不挂载到公网出入口。第二接口调用要加权限校验不能变成谁都能调用的公开接口否则会变成内网数据泄漏通道。第三日志不能打公式原文异常堆栈可以打但不能拼正文。第四MathML和SVG内容入库前要做HTML实体转义防止XSS注入。第五如果系统对接了审计系统转换行为本身也要写审计日志包括时间、用户、文档ID。我们做第一个军工项目时安全评审会专门有人追问“公式转换的数据链路走外部网络吗”这个问题要在方案里写得明明白白否则评审批不过后面全白干。6. 实战排坑我踩过的坑和排查心得6.1 现场问题速查表现象可能原因解决方案公式变成上下标小字符OMML节点未提取只保留了Unicode文本确认粘贴事件捕获是否生效检查qSA转义写法公式变成灰色方框OLE对象被过滤兜底弹窗建议升级Word版本或使用图片模式粘贴后内容完全丢失拦截逻辑误判了所有粘贴事件检查isInEditor判断逻辑确保无公式时放行公式插入后空白MathJax异步渲染未完成就插入用MathJax.typesetPromise包裹后再insertHtml表单提交后公式消失后端XSS过滤误杀了MathML/SVG标签调整服务端过滤规则对公式容器放行IE下DOMParser不可用老浏览器兼容问题换用MathJax 2.7.9 降级提取方案转换接口偶发超时XSLT模板每次重新加载使用Templates单例复用公式和文字垂直不对齐公式容器行高和基线问题给公式容器设置vertical-align: middle6.2 几个容易被忽略的细节第一个是Word粘贴时中文乱码的叠加问题。公式处理链路只处理OMML节点但文档正文的中文如果经过编码转换变成乱码公式再正常也没用。这个问题一般出现在后端接收OMML字符串时字符集设置不对。统一用UTF-8接收前端在发送时明确Content-Type是application/json;charsetUTF-8基本能根治。第二个是MathJax渲染完成的时机。MathJax渲染是异步的如果你在renderMathMLToHTML返回前就把内容insertHtml编辑器里会看到一堆MathML标签源码。需要用Promise或者async/await确保渲染完成后再插入。我见过很多开发者栽在这上面。第三个是占位符替换的顺序问题。如果文档中有多个公式我前面用【公式占位】做标记再replacereplace默认只替换第一个匹配所以循环里要按顺序一一对应。如果公式内部包含占位符文本就会错位稳妥做法是先把公式从HTML中抠出来存数组处理完后再按索引拼回去不要用字符串replace。第四个是“公式与文字不对齐”的处理。公式容器插入后经常出现公式比旁边文字高出半个头或低半个头的情况。给公式容器加vertical-align: middle和line-height: 1通常能改善。各种公式尺寸不同还需要微调MathJax的ex-height参数这个参数控制公式与行内文字的垂直对齐基准。第五个是“粘贴时卡顿”的排查。如果一次粘贴了几十个公式MathJax逐个渲染会导致页面短暂卡死。解决办法是批量收集所有MathJax容器一次性typeset或者做个异步队列每次渲染5个分片处理。6.3 后续扩展的想象空间这套链路稳定后公式问题其实只解决了“输入”端。顺着这个思路后面还能扩展出两个很有用的功能——一个是把库里的MathML/SVG导出为Word文档时反向把公式还原成OMMLWord打开就是可编辑的公式另一个是基于MathML做公式全文检索用户搜“勾股定理”能精确搜到这个公式。这两个方向我们在后续项目中验证过可行性工程量主要在格式转换上有兴趣的可以去试。我在实际项目里体会最深的一点是公式乱码这类问题表面看是技术bug本质上是对数据格式理解不透彻。搞清楚Word复制到剪贴板时带了什么、ueditor丢掉了什么、MathJax能渲染什么整条链路打通并不难。关键在于不要被“乱码”两个字带偏急着去清洗字符、转码那是治标不治本。把精力花在结构化数据的提取和转换上才是真正能交差的办法。
返回列表