1. 政务场景里,为什么公式导入是个绕不开的坑
接手这个项目之前,我一直觉得“从Word复制内容到网页编辑器”是个已经解决得很好的基础功能。直到真去处理税务、统计、教育口子的稿件时才发现,普通文字和图片粘贴是一回事,公式粘贴完全是另一回事。
政务平台的编辑人员每天要处理大量Word文稿,这些文稿里有三类内容逃不开公式:一是业务计算类,比如退抵税额、补贴金额、绩效评分公式;二是统计解读类,比如标准差、增长率、加权平均公式;三是教育科研类,比如考试大纲里的函数表达式、数列求和公式。以前的做法基本是截图后贴图,公式区域和正文的排版经常对不齐,到了微信公众号上更是要反复调整,运维成本极高。
我这边接到的项目,是某区级政务内容发布平台的后台改版。后台原先用UEditor,编辑人员把带公式的Word内容粘贴进来,公式要么变成乱码,要么直接丢失,推送到微信公众号后更是惨不忍睹。后来我们换成CKEditor,并且打通了“Word公式进编辑器、编辑器公式进公众号”这条链路。整个项目的核心,就一件事:让公式像普通文字一样,能编辑、能保存、能发布,全程不丢不烂。
这次改造有几个特殊约束。首先是内网环境,不能依赖公网CDN,公式渲染库必须本地化部署。其次是发布流程,政务文章有审批制度,不能全自动发公众号,只能走半自动流程。最后是内容长期可维护,公式不能转成图片就完事,还必须保留可编辑的源码。这几个约束决定了整个技术方案的走向。
1.1 政务内容的公式,不只是数学符号
先看几张实际的公式清单,你就明白为什么不能一刀切处理了。
从业务类型看,政务平台里的公式可以分成三类:
| 内容类型 | 典型场景 | 公式样子 |
|---|---|---|
| 业务计算 | 退抵税、补贴、绩效 | 退税额 = 进项税额 - 销项税额 |
| 统计解读 | 数据发布、指数计算 | 标准差、增长率、加权平均 |
| 教育科研 | 考试大纲、教案分享 | 二次方程、函数、数列求和 |
这三类内容有一个共同特征:公式往往是行内嵌在正文里的,不是独立展示的图表。比如“根据公式\bar{x} = \frac{1}{n}\sum x_i计算平均值”,公式前面有正文、后面也有正文,排版上必须保持行内对齐。截图法在这种场景下很难看,因为截图图片的基线很难和文字对齐,稍微一拖动就歪。
还有一层麻烦是:政务文档往往来源多样。有的从Word 2016新建,有的从旧版WPS转换过来,还有的干脆是扫描件文字识别后再编辑。公式的底层结构千差万别,有的规范无比,有的缺胳膊少腿。开发阶段用规范文档测试一切正常,到了验收阶段一用真实文档就各种翻车。
1.2 从Word到公众号,公式要走四步转换
当我把“从Word复制公式到CKEditor再发布公众号”拆解成技术链路时,发现这条链路比想象中长。很多人以为这只是一次复制粘贴,实际上公式在中间要经历四种形态的转换。
第一步,Word内部格式。Word里的公式以OMML(Office Math Markup Language)存储,这是一种微软定义的数学标记语言。当你从Word复制内容时,OMML结构会混在剪贴板的HTML片段里一起出来。
第二步,网页可识别格式。网页端不认识OMML,需要转换成MathML或LaTeX,MathJax才能渲染。这一步是技术核心,也是坑最多的地方。
第三步,编辑器内可视格式。编辑人员看到的是渲染后的公式,而不是源码。CKEditor配合MathJax能做到这一点,但体验上的细节需要打磨。
第四步,公众号发布格式。微信公众号编辑器不识别MathJax,也不支持前端脚本,公式必须预渲染成图片,再跟随正文一起粘贴进去。
这四步任意一步断掉,公式就会在某个环节消失。项目初期我们试过用现成的Word粘贴插件,编辑区里公式显示正常,存储也没问题,结果推到公众号预览后公式区域全是空白。后来排查下来就是卡在了发布这一步。
1.3 三条技术路线的选型对比
动手前,我们把方案摊在桌面上比了一轮,最终结论还挺明确的。
方案A是直接用CKEditor配套的MathType插件(Wiris),它公式编辑体验很好,也支持从Word粘贴公式,但有两个问题:一是商业授权费用不低,政务项目采购流程复杂;二是它的OMML转换依赖Wiris服务器在线支持,内网环境直接卡死。
方案B是CKEditor自带的mathjax插件加自研转换逻辑,编辑器负责交互,MathJax负责渲染,OMML到LaTeX的转换由自己实现或引开源库。这个方案工作量最大,但可控性最强,不依赖外部服务,内网也能跑。
方案C是完全放弃公式编辑,统一转图片。技术上最简单,但后续内容一旦需要修改,编辑人员得重新进Word改公式再截图,长期维护成本非常高,政务平台往往要用上十年,这个账划不来。
综合对比后选了方案B。核心思路是:公式在数据库里以LaTeX源码形式保存,编辑器里以MathJax渲染形式呈现,发布到公众号时再临时转为图片。这样既保证编辑体验,也保证长期可维护性。
2. 公式的“三张皮”:OMML、MathML与LaTeX
很多开发者卡住的第一个点,其实是分不清OMML、MathML和LaTeX三者到底是什么关系。这里我用最直白的方式讲清楚。
2.1 Word贴出来的OMML结构长什么样
OMML的完整名称是Office Math Markup Language,它是Word文档内部使用的数学标记格式。当你在Word里用公式编辑器敲了一个公式,复制它,再粘贴到网页编辑区时,浏览器剪贴板的HTML片段会包含类似这样的结构:
<m:oMath> <m:r> <m:t>E=mc</m:t> </m:r> <m:sSup> <m:e> <m:r><m:t></m:t></m:r> </m:e> <m:sup> <m:r><m:t>2</m:t></m:r> </m:sup> </m:sSup> </m:oMath>m:前缀的节点都在xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math"命名空间下。OMML的特点是和Word底层文档结构强绑定,比如分数用<m:f>,上标用<m:sSup>,根号用<m:rad>。这些结构网页端根本不认识,如果编辑器没有处理逻辑,浏览器会直接把它当普通XML标签显示,于是你就在编辑区里看到一堆“m:oMath”的字样。
2.2 MathML和LaTeX各自的优劣势
MathML是W3C定义的数学标记语言标准,结构化强、语义化强,但标签非常冗长。写一个分数用MathML表达,可能要十几个标签。LaTeX则是文本标记语言,简洁直观,一行\frac{1}{2}就表示二分之一,不管是存储还是阅读都比MathML高效得多。
政务CMS里,我强烈建议数据库统一存LaTeX。原因有三:一是占用空间小,几万个公式也就是几兆的事;二是纯文本形式方便在后台SQL里检索、批量替换;三是LaTeX生态成熟,无论对接公众号、导出PDF、还是生成静态页面,都有现成的转换工具。MathML可以用作中间交换格式,但没必要作为主存储格式。
2.3 我们最终采用的转换链路
经过多轮技术验证,最终确定的链路是:
OMML(从粘贴HTML中提取)→ 前端解析转换为MathML → 服务端接口将MathML转换为LaTeX → CKEditor内MathJax渲染
为什么后面又加了“前端先将OMML转MathML”这一步?主要是为了减少服务端压力。OMML转MathML是纯文本解析,放前端做很轻盈;MathML转LaTeX涉及大量规则判断和语义分析,放服务端更稳定,也方便后续扩充功能,比如批量转换历史文档。
这里要特别强调一个原则:转换工具必须做异常兜底。有些老版本Word生成的OMML并不规范,转换器解析失败的情况很常见。失败时一定要在编辑器里保留原始OMML内容,或者弹出明确提示,绝不能静默丢弃。否则用户辛辛苦苦排好的版面,发布时才发现公式丢了,就真的很难追责了。
3. CKEditor公式导入的完整实现步骤
下面进入实操部分。这个项目后台基于PHP,前端用的是CKEditor 4,公式插件用官方mathjax插件。选CKEditor 4不是因为它比5先进,而是政务项目里大量老系统已经在用CKEditor 4,插件体系成熟,迁移成本低。下面所有配置和代码都以CKEditor 4为例。
3.1 编辑器配置与MathJax本地化部署
CKEditor 4的mathjax插件,官方给的配置通常是直接引公网CDN的MathJax,在政务内网完全不可用。我们的做法是把MathJax 2.7.9完整安装包放到内部静态资源服务器,配置时指向本地地址。
CKEDITOR.replace('content', { extraPlugins: 'mathjax', mathJaxLib: '/static/mathjax/MathJax.js?config=TeX-AMS-MML_HTMLorMML', removePlugins: 'sourcearea', height: 480, disableNativeSpellChecker: true, forcePasteAsPlainText: false });mathJaxLib这个参数决定了公式预览时的MathJax加载地址,必须改成内网可访问的URL。TeX-AMS-MML_HTMLorMML这个config让MathJax同时支持LaTeX和MathML两种输入方式,粘贴进来的内容不管是哪种格式都能渲染。
disableNativeSpellChecker和forcePasteAsPlainText这两个参数容易被忽略。前者是为了防止浏览器在公式区域做拼写检查干扰,后者是为了确保粘贴时不会把HTML强行转成纯文本,否则OMML结构在进入编辑器前就被剥掉了。
3.2 粘贴拦截与OMML提取
公式导入的核心逻辑在paste事件里。我们的做法是:在粘贴内容正式进入编辑器之前截获它,识别OMML结构,转换为LaTeX,再放行插入。
editor.on('paste', function(evt) { var html = evt.data.dataValue; if (html.indexOf('<m:oMath') === -1) { return; // 没公式,走默认逻辑 } evt.cancel(); // 先取消默认插入 var omathList = []; var regex = /<m:oMath[\s\S]*?<\/m:oMath>/g; var match; while ((match = regex.exec(html)) !== null) { omathList.push({ raw: match[0], index: match.index }); } // 这里实际会用Promise.all收集所有异步转换 var convertTasks = omathList.map(function(item) { // 前端 OMML -> MathML var mathml = convertOmmlToMathml(item.raw); // 服务端 MathML -> LaTeX return fetch('/api/mathml2latex', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mathml: mathml }) }).then(function(res) { return res.json(); }).then(function(data) { if (data.latex) { var latexSpan = '<span class="math-tex">\\(' + data.latex.replace(/\\/g, '\\\\') + '\\)</span>';