前两年接了一个政务网站内容管理系统的二次开发项目,技术栈比较老,后台编辑器还是KindEditor 4.1.x。需求方提了一个特别具体的诉求:编辑们平时写通知、发政策解读、整理会议纪要,经常要把Word、PDF、Excel里的内容搬进编辑器。以前的操作路径是先另存为纯文本,再粘贴进去重新排版,结果格式全乱、图片丢失、表格挤破。
他们希望做一个“多格式文档智能填充”,选中一个文件,系统自动识别类型,把内容按公文的编辑习惯整理好,直接落到编辑器里。
做完之后我的感受是,这个需求本质上不是在让KindEditor变聪明,而是在它的能力边界外面补一条“预处理流水线”。整条流水线由三层组成:服务端格式转换、安全清洗、结构化排版。这篇文章就把整体设计和踩坑过程完整记录下来,给还在老CMS上维护项目的同行做个参考。
1. 先把问题盘清楚:KindEditor在政务CMS里的“多格式文档”到底难在哪
1.1 政务场景为什么不能直接换编辑器
很多人第一反应是:都2024年了,KindEditor这玩意早就不更新了,直接换成CKEditor 5或者TinyMCE不就行了?这个想法在普通项目里成立,但在政务CMS里基本行不通。
我看过的这类老系统,KindEditor往往不只是“一个编辑器”那么简单。它跟一堆自定义组件绑在一起:玉兰图片上传接口、附件管理插件、内容审核流程、发布接口。有些页面里甚至写了依赖KindEditor私有API的脚本,换掉编辑器意味着所有发布通道、工具栏配置、内容模板都要跟着动一遍。这个改造量的风险和成本,不是业务方愿意承担的。
另外政务类的网站有个特点:存量数据特别重要。旧文章里大量内容是用KindEditor生成的HTML结构,比如内联style、特定的换行方式、表格嵌套写法。换个编辑器之后,这些历史内容在编辑状态下能不能正常回显、能不能兼容旧模板,是个巨大的不确定项。
所以这个项目的选型方向注定是:不换引擎,扩展能力。把“多格式文档智能填充”做成一个附加功能模块,通过标准接口对接KindEditor,原有编辑流程不动。
1.2 “智能填充”不是AI,而是三层流水线
需求方口中的“智能”,其实是非常具体的办公场景诉求。我把它拆成三层:
第一层是格式适配,把doc、docx、xls、xlsx、pdf这些不同格式统一转成一种可编辑的中间HTML。这一层解决“打不开”“复制出来是乱码”的问题。
第二层是安全适配,把转换出来的HTML做严格的白名单清洗。政务系统对安全性要求很高,服务端不能存储带脚本、带事件属性、带危险标签的富文本内容。这一层解决“能用但不敢用”的问题。
第三层是排版适配,把清洗后的HTML按照公文习惯重新编排:标题识别、字体映射、首行缩进、序号段落层级调整。这一层解决“内容进来了但没法看”的问题。
三层解耦的好处很明显:每一层都可以独立替换、独立测试。比如后来我们发现LibreOffice转表格不够理想,只需要调整格式适配层的策略,完全不用动前端的编辑器集成逻辑。
2. 整体方案设计:为什么选“服务端转换为主、前端清洗兜底”的架构
2.1 服务端转换为主的原因
这个架构决定是在项目第一周就定下来的。当时对比过纯前端方案,比如用mammoth.js在浏览器里直接解析docx,或者用SheetJS解析Excel,好处是不占服务器资源,坏处也很明显。
首先是浏览器兼容性。这类政务CMS还在用IE内核的不少,前端方案在老浏览器上未必跑得稳。mammoth.js虽然能解析docx,但遇到老版本IE就直接卡住。其次是安全审计问题,政务系统做等保测评时,要求服务端存储的富文本必须是经过净化处理的。如果只在前端转换,服务端接收的是“不可信”的HTML代码,这个口径过不了审计。
服务端转换为主的架构还有一个好处:处理能力不受浏览器限制。政务文档经常几十页甚至上百页,带大量表格和图片的章排版,前端解析这种文件会直接把编辑页卡死。但在服务端跑转换,编辑器界面始终流畅,用户体验是好的。
2.2 统一中间结构:一份“干净的基础HTML”
多格式转换最怕的是“各转各的”,Word转出一套结构,Excel又转出另一套结构,后面的清洗和排版逻辑得为每种格式各自维护一套规则,项目直接失控。
我们在服务端定了一个统一中间结构,所有格式的文档转换完成后都落成这个结构。这个结构本质上是一份“受限HTML”,只有这些标签允许出现:p、h2、h3、table、thead、tbody、tr、th、td、ul、ol、li、img、strong、em、span、div。
同时定了几条硬性限制:不允许script、iframe、object、embed等危险标签;不允许onclick之类的事件属性;style属性里只允许字体、颜色、缩进等展示类样式;img标签必须有对应的附件标识,方便做图片转存。
这样设计之后,后面的安全清洗和排版优化的逻辑只需要处理这一种结构。每个环节之间传的参数是确定的,出问题也好定位。
3. 核心实现:把Word/PDF/Excel装进KindEditor的完整实操
3.1 服务端转换层:LibreOffice+Tika的务实组合
服务端技术栈是Java,这是政务CMS里最常见的形态。转换层的核心组件选了Apache Tika加LibreOffice。Tika负责内容识别,LibreOffice负责把各种Office格式统一转换成中间HTML。
Tika在这里的角色很关键。政务系统上传的文件经常存在扩展名和真实格式不一致的情况,有人把docx改成doc,有人把xlsx改成pdf。如果只信扩展名,转换层会直接崩掉。Tika通过读取文件头部的magic bytes识别真实格式,这一步稳得很。
转换逻辑的核心代码大致是这样:
// 1. 用Tika识别真实格式 Tika tika = new Tika(); String mediaType = tika.detect(inputStream, fileName); // 2. 根据真实格式分派转换策略 if (mediaType.contains("word") || mediaType.contains("excel")) { // LibreOffice headless转换 String cmd = "soffice --headless --convert-to html:\"HTML (StarWriter)\" " + "--outdir " + outputDir + " " + inputFile; Process p = Runtime.getRuntime().exec(cmd); p.waitFor(120, TimeUnit.SECONDS); } else if (mediaType.equals("application/pdf")) { // PDF走PDFBox文本提取 PDDocument doc = PDDocument.load(inputFile); PDFTextStripper stripper = new PDFTextStripper(); String text = stripper.getText(doc); doc.close(); }这里有几个要注意的细节。第一个是soffice进程管理,LibreOffice第一次启动比较慢,进程不会自动退出,如果不处理,连续转换多次后会攒一堆进程。解决办法是启动命令加上临时用户目录参数:-env:UserInstallation=file:///tmp/lo_profile_xxx,每个转换任务用独立配置目录,转换完直接结束。
第二个细节是超时控制。政务文档大的有几十上百页,LibreOffice转得慢,但一般也就在十几秒内完成。设120秒超时足够了,超过就认为是文档损坏或者格式特殊,直接返回错误提示,不无限等待。
保留格式方面,实测下来LibreOffice转docx的效果综合最好,标题样式、段落缩进、表格边框基本都能保留下来。转excel会偶尔出现合并单元格丢失的情况,但好在政务场景里Excel导入后通常还要再调样式,影响可控。
3.2 政务排版再造:从“转换HTML”到“结构化智能填充”
LibreOffice转出来的HTML直接填进编辑器是没法看的。字体五花八门,段落没有缩进,标题样式是Word里的Heading 1,并不符合公文的排版习惯。
政务网站的公文排版是有明确规范的:大标题用二号小标宋体,正文用三号仿宋_GB2312,段落首行缩进两个字符,一级二级标题各有对应的字体和层级。
这里说的“智能填充”,核心就是做了一个排版优化器,把转换后的HTML按规范重新编排。排版优化器的主要逻辑分为四步。
第一步是标题识别。docx转换后的HTML里,标题段落通常会带Heading标记,或者有对应的style属性。把这些段落统一映射成h2和h3,去掉Word里多余的背景色和字体定义,交给编辑器端CSS控制展示效果。
第二步是正文字体映射。清洗掉所有直接的font-family和font-size,然后统一给正文段落加上仿宋_GB2312、三号的样式。这一步用正则匹配段落的style属性做到,简单直接。
第三步是序号层级识别。公文里的序号层级通常是“一、”“(一)”“1.”这个结构。通过正则识别段首序号,决定该段的缩进层级。一级标题加粗,二级标题缩进两字符,正文段落统一首行缩进两个字符。
第四步是表格处理。对转换出来的table标签做统一修正:设置宽度为百分比或auto,避免超出编辑器可视区域;清理掉单元格内部的嵌套div;合并连续相同的相邻单元格,减少转换过程中的结构冗余。
这套排版规则跑完之后,导入的内容在编辑器里已经非常接近正式公文的形态了。编辑们再手动微调,工作量下降了百分之七十以上,这也是整个项目里他们评价最高的部分。
我贴一段排版优化器的核心逻辑,方便理解正则映射是怎么做的:
// 打方形阵段落的小集团处理 public String restructure(String cleanedHtml) { // 标题映射 html = html.replaceAll("<h1([^>]*)>", "<h2$1>") .replaceAll("<h4([^>]*)>", "<h3$1>"); // 去掉Word多余字体 html = html.replaceAll("font-family:[^;\"']+;?", "") .replaceAll("font-size:[^;\"']+;?", ""); // 序号段落缩进 html = html.replaceAll("(<p[^>]*>)(\\s*([一二三四五六七八九十]+))", "$1 style=\"text-indent:2em;font-weight:bold;\"$2"); return html; }处理完的HTML入库前还要再过一次白名单过滤器,防止附件里有恶意代码被带进来。这一步用的是白名单模式而不是黑名单模式,黑名单永远追不上攻击方式的更新,白名单固定放行安全标签,其余全部拿掉。
3.3 前端扩展:给KindEditor加一个“导入文档”按钮并正确落库
服务端的转换和排版逻辑完成之后,前端的工作就是把转换结果送回KindEditor并展示出来。
KindEditor的工具栏是支持自定义项的。初始化时在items数组里加上一个自定义按钮,然后用afterCreate回调去绑定点击事件。点击按钮触发一个隐藏的file input,上传文件后拿着返回的干净HTML,调用KindEditor的insertHtml或者html方法写入编辑区。
这里有一个需要根据需求区分的细节:如果用户选中了“按模板替换全文”,走的是editor.html(cleanHtml);如果用户只是想在当前光标处插入Excel表格或者PDF内容,走的是editor.insertHtml(cleanHtml)。这两者的逻辑不一样,产品层需要给用户一个明确的选项。
KindEditor.create('#content', { items: ['source', 'undo', 'redo', 'docimport', '|'], afterCreate: function() { var editor = this; var btn = editor.toolbar.docimport; // 绑定file input btn.click(function() { document.getElementById('docImportInput').click(); }); } }); // 文件上传回调 function handleImportResult(data) { if (data.code === 0) { var editor = KindEditor.instances[0]; if (data.action === 'replace') { editor.html(data.content); } else { editor.insertHtml(data.content); } } else { alert(data.message); } }文件上传接口走的是单独的路由,不经过CMS原有的附件上传模块。原因在于这个接口需要返回“转换后的HTML”,跟普通图片上传返回URL的逻辑完全不一样,混在一起会互相干扰。
还有一点很关键:KindEditor在收到通过html方法注入的内容后,自己内部会再过一遍HTML过滤器,把非白名单标签去掉。利用这个特性,前端相当于多了一层兜底清洗,即使服务端清洗逻辑有遗漏,敌人想通过编辑器注入攻击代码也很难成功。
4. 常见问题与排查技巧实录
4.1 高发问题速查表
整理一下这个功能上线后遇到的问题Top级清单,对照排查效率会更高:
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| 导入docx后字体全变宋体 | 转换层没有映射仿宋_GB2312字体 | 在排版优化器里增加字体映射规则,把font-family统一替换 |
| 图片在发布后不显示 | 转换产生的图片文件没有转存到CMS附件库 | 检查附件转存逻辑,必须把图片从临时目录复制到正式存储目录 |
| 表格超出编辑器宽度 | Word里表格列宽固定,转HTML后保持固定像素值 | 清洗table样式,把width统一改成百分比或auto |
| 导入PDF内容为空 | 扫描版PDF没有文本层,提取不到文字 | 识别为扫描件,提示用户以附件形式上传,不做文本填充 |
| 转换接口超时 | 文档过大或LibreOffice进程卡死 | 设置120秒超时,用独立临时用户目录避免进程冲突 |
| 页面乱码 | 老系统页面用GBK编码,转换产物是UTF-8 | 统一全链路UTF-8,数据库连接串加characterEncoding参数 |
| 工具栏按钮不出现 | KindEditor版本差异或items配置错误 | 检查afterCreate回调里的按钮引用,优先用全局插件方式注册 |
4.2 三个典型踩坑复盘
第一个坑是字符集。这系统部署给一家下属单位时,他们的服务器数据库字符集是GBK。LibreOffice转换出的HTML是UTF-8编码,直接存库之后,所有中文内容在页面上全部变成乱码。排查了半天,发现是JDBC连接串里没有指定characterEncoding。解决办法是统一全链路UTF-8,数据库连接串加上useUnicode=true&characterEncoding=UTF-8,同时把数据库表和字段的字符集手工改成utf8mb4。这个坑在政务项目里特别常见,因为老库建设时间早,很多都是GBK。
第二个坑是图片转存。Word里插的图片,LibreOffice转HTML时会把图片导出成独立文件放到转换目录里。一开始只把HTML文本入库了,图片目录还在临时路径上,发布之后图片全部显示不出来。这个问题上线第一天就被编辑们发现了。解决办法是在转换层写一个附件转存组件,把临时目录里的图片文件转存到CMS的统一附件表或者存储目录,同时把HTML里img标签的src指向替换成新的存储地址。注意这里替换src时要处理好相对路径和绝对路径的坑,数据库里存的必须是能访问的完整URL。
第三个坑是表格失控。政务文档里的表格经常是复杂合并单元格结构的,LibreOffice转出来的HTML会生成大量嵌套table结构。KindEditor插入这种嵌套表格之后,编辑器里的显示还算正常,但发布出去的前端页面会乱成一团。解决思路是在HTML清洗阶段加一个表格拆分逻辑:把嵌套的子表格从父表格中拆出来,合并相邻相同单元格,把固定px宽度改成百分比。这一步当时花了大概两天时间反复调试,最后的效果是任何表格进来,至少不会撑破页面布局。
上线之后我还发现一个经验:一定不能用开发自造的简单样例文档做测试。政务系统这批人会用到极端格式,比如红头文件套红、盖章扫描件、一堆嵌套表格的台账。我们用真实文档回归了几十轮,每次都能发现新的转换问题。这套“真实文档库回归测试”的方式,建议做同类项目的同学都保留下来。
5. 最后再分享一点个人体会
做完这个功能最大的心得是:扩展KindEditor这种已经停止迭代的编辑器,最稳的方式不是往它内部打补丁,而是把复杂度全部拆到服务端,把编辑器当成一个展示和交互的“哑终端”。
因为KindEditor本身并不擅长解析文档,解析是个重活,放浏览器里受兼容性和内存限制,放服务端却能随意拿性能换能力。
另外,真实的政务内容场景里,“智能”往往不是那种AI自动生成内容的智能,而是让旧系统里的人少做几次复制粘贴、少修几次排版。一个文档导入按钮,背后是一整套格式适配、安全清洗、排版再造的流水线,用户看到的是“一下就填好了”,看不到的是服务端防了脚本注入、防了表格溢出、防了图片丢失。
这个功能后续如果要继续扩展,可以做的地方还很多。比如接入文档预览的在线预览服务替代现在的转换方式,或者对扫描件引入OCR识别能力,把“文字进不来”的PDF也纳入填充范围。但不管怎么扩,三层流水线的框架基本不用动,这大概就是先把架构想清楚的价值所在。