做项目的都知道,越是不起眼的需求越容易在验收时翻车。最近帮一个老牌办公系统做功能改造,客户提了个平常到不能再平常的需求:内容在KindEditor里编辑完,点一下“导出PDF”,整个文档要自动转成PDF存进档案库。乍一听,这不就是HTML转PDF吗?可一往下聊就发现,项目所在的环境对控件和组件有明确的国产化要求,wkhtmltopdf、无头Chrome那一套工具链根本过不了选型评审。于是这个“边角需求”直接升级成了整场改造里最麻烦的模块之一。
这篇文章就从这次改造讲起,完整拆解在KindEditor这类老牌富文本编辑器上,如何用满足国产化要求的服务端控件/组件,把编辑器内容自动格式转换成PDF。内容包括方案取舍、环境准备、核心实现流程,以及我实际踩过的坑。适合正在维护遗留办公系统、又撞上组件国产化替换需求的朋友参考。
1. 需求不是“转PDF”,而是“在受限环境下把HTML渲染成可归档PDF”
1.1 KindEditor转PDF的典型场景
用到KindEditor的系统,绝大多数是上了年纪的办公类系统:公文管理、公告发布、简历填写、问题单、合同备忘、多人在线协作编辑页面。KindEditor当年能火,靠的是体积小、上手快、中文支持好,集成到后台管理界面非常顺手。但它在输出内容的时候,交出来的是一段“自由散漫”的HTML——标题、段落、图片位置全靠class甚至直接靠全局样式表撑着。它不是一个抽象语法树,更不算一份结构化的文档,它就是“页面里某个可编辑区域的DOM快照”。
一旦有PDF归档需求,问题就暴露了。PDF是一种对排版稳定性和字体嵌入要求极高的格式,而KindEditor输出的HTML恰恰在这两点上都极其松散。我接触到的需求基本就三类:一是点按钮手动导出,用于审批流程留档;二是保存后自动转PDF,用于跨系统归档或者做全文检索;三是把历史存量文章批量转成一批PDF,用于电子档案迁移。这三类场景对性能和异步任务的要求完全不同,方案设计不能一概而论。
先说需求一,最稳妥的做法就是同步转换:前端把内容提交到后端,后端在请求周期内完成PDF生成,直接把文件流返回浏览器下载。需求二就必须考虑异步化,因为保存动作本身不能因为转换失败而卡住主流程,否则用户会明显感知到保存变慢。需求三则是批处理场景,需要任务队列、失败重试、进度反馈,和前两个的架构设计差别很大。
1.2 三条技术路线的取舍
针对这个需求,业内早就有现成方案,但每条路线的适用条件完全不同,我先给结论:在带“国产化控件选型”约束的项目里,前两条路线大概率会在评审阶段被否掉,但理解它们的前因后果很有必要。
第一条是纯前端转换。常见做法是html2canvas把KindEditor的编辑区域截图,再用jsPDF把图贴进PDF;或者先把DOM和样式重新拼一遍布局,再用开源库直接输出PDF。优点是根本不用动服务端,部署压力小。缺点是html2canvas是“截图思维”,对复杂表格、滚动区域、浮动层经常截不全;jsPDF对中文排版支持弱,中文字体嵌入要做大量额外工作;分页基本靠猜,PDF里的文字出现截半行的情况很常见。真要硬抗纯前端方案,最后代码量往往比后端方案还大,效果还不稳定。
第二条是国外主流渲染方案。效果最好的是wkhtmltopdf,以及用无头Chromium渲染的puppeteer系列工具。它们确实不叫“控件”,而是命令行工具或者服务端模块,渲染CSS的能力强,几乎能做到“所见即所得”。但要命的地方在于:部署Chromium依赖很多系统库,容器里还得处理沙箱权限,安全合规团队看到供应链清单里一堆半开源组件,第一轮就直接毙掉。所以在有国产化约束的项目里,这条路走得通但不长久,后面还要返工。
第三条就是本文要展开的做法:使用国产化PDF生成组件/引擎,部署在服务端,前端只保留一个“提交内容、拿回文件”的接口。这类引擎通常提供Java、C++或Python的SDK,有些还支持独立微服务部署方式。它们对内联HTML的解析能力不一定比无头浏览器强,但对“字体注册、页边距、页眉页脚、水印、重复表头”这些企业级PDF特性支持得非常好,可控性也强。这也是我认为在国产化环境里唯一站得住脚的路线。
1.3 “国产化控件”的真实形态,先破个误区
这里必须先破一个认知误区。十几年前一说控件,大家想到的是ActiveX,要浏览器里装客户端插件,基本只能跑在IE上。现在这种模式早就没有生存空间了,国产化环境下的浏览器大多基于Chromium内核,本身就不含IE的控件通道。所以现在项目文档里写“控件”,实际部署形态已经演变成两类。
一类是服务端SDK组件:以Jar包、Linux的so、Windows的dll形态存在,被业务系统直接调用,通过API传HTML、输出PDF流。另一类是独立的转换微服务:把PDF生成引擎封装成HTTP服务,业务系统通过POST请求提交内容,再取回文件。后者对跨语言系统更友好,也是我实际项目中推荐的形态。
想明白这一点,总体思路就清晰了:KindEditor在页面里负责“编辑内容”,提交时把编辑器内容送到服务端,服务端调国产化控件把HTML渲染成PDF,再把PDF返回给浏览器下载或存档。后面所有技术细节,其实都在围着“HTML如何能被排版引擎理解和渲染”转。这也是项目中最容易出彩、也最容易踩坑的部分。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 集成前准备:字体、接口、交互三件事先落地
2.1 先在转换服务器上把中文字体搞定
很多人做HTML转PDF,第一步就栽在字体上。KindEditor里用户选“宋体”“仿宋_GB2312”“黑体”都是很常规的操作,但PDF引擎拿到HTML后,如果目标字体在服务器上不存在,它不会报错,而是用默认字体替换,结果就是一堆方块或者难看的回退字体。
所以在集成国产化控件之前,第一步一定是检查转换服务器上的中文字体。Linux服务器上用fc-list :lang=zh看一下已安装的字体,如果没有需要的字体,就把字体文件放到/usr/share/fonts/目录下,然后执行fc-cache -fv刷新字体缓存。这里要注意:不仅仅是系统层面要有字体,PDF转换引擎通常还有自己独立的字体配置项,需要在引擎配置里把字体文件路径或者字体名称显式注册进去。这一步漏了,系统字体装了也白装。
实操中我的经验是直接把宋体、黑体、仿宋、楷体都注册好,并且给它们起好别名。因为在KindEditor生成的HTML里,font-family经常出现“宋体, SimSun, serif”这种多字体回退链,引擎解析时如果只认其中一个别名,也会出现字体被替换的怪问题。做好字体映射,后续的乱码问题能避开一半。
顺便一提,如果客户对字体文件本身的版权有要求,不要随意从网上下一个字体文件丢上去,最好由客户方提供经过授权的正版字体。这在金融、政务类项目里尤其常见,字体文件的合规性也是验收会翻车的一个点。
2.2 后端接口设计:想清楚收什么、返回什么
后端接口是整个转换链路的枢纽。我最初设计的接口很简单:接收文章标题和HTML正文,返回PDF文件流。但实际开发后才发现,这里有几个细节必须提前考虑。
第一是报文大小。KindEditor里如果用户直接粘贴图片,图片会以base64字符串形式嵌在HTML里。一张手机拍的照片可能就是两三MB,base64编码后这对接口的请求体大小影响很大。如果后端容器或者Nginx没调大限制,直接返回413错误。我一般会把client_max_body_size和容器的请求大小限制统一调到20MB以上,但更根本的解法是架构层面让图片尽快从HTML中剥离,这个在第三章细说。
第二是返回格式。接口可以直接返回application/pdf让浏览器下载,但更规范的做法是在响应头里带Content-Disposition,这样前端可以从中提取文件名,避免前端再硬编码一套命名规则。另外还要考虑转换失败时的错误体结构,最好是统一JSON错误结构,前端好判断是网络错误还是转换失败。
接口结构大致是这样:
java复制@PostMapping(value = "/api/docs/convert-pdf", produces = MediaType.APPLICATION_PDF_VALUE)
public ResponseEntity<byte[]> convertPdf(
@RequestParam("title") String title,
@RequestParam("html") String html) {
byte[] pdfBytes = pdfConvertService.convert(title, html);
String fileName = URLEncoder.encode(title + ".pdf", "UTF-8");
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + fileName)
.contentType(MediaType.APPLICATION_PDF)
.body(pdfBytes);
}
2.3 前端按钮与下载交互,别让体验毁在细节上
KindEditor支持自定义工具栏,但方式稍微绕一点。最稳妥的做法是在afterCreate回调里往工具栏追加一个自定义按钮,点击后触发异步请求。需要注意的点是给按钮加上loading状态,防止用户重复点击产生重复文件;同时导出过程如果涉及大文档,最好弹一个进度提示,让用户知道系统在处理而不是卡死。
一个可用的前端核心代码长这样:
javascript复制KindEditor.ready(function (K) {
window.editor = K.create('#editor-content', {
width: '100%',
height: '400px',
filterMode: false,
afterCreate: function () {
var self = this;
var btn = K('<span class="ke-button-export">导出PDF</span>');
self.toolbar.append(btn);
btn.click(function () {
if (btn.hasClass('loading')) return;
btn.addClass('loading');
exportPdf();
});
}
});
});
function exportPdf() {
var html = editor.getData();
var formData = new FormData();
formData.append('title', document.getElementById('docTitle').value || '未命名文档');
formData.append('html', html);
fetch('/api/docs/convert-pdf', { method: 'POST', body: formData })
.then(function (res) {
if (!res.ok) throw new Error('转换失败');
return res.blob();
})
.then(function (blob) {
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = '公文_' + Date.now() + '.pdf';
a.click();
URL.revokeObjectURL(a.href);
})
.catch(function (err) {
alert('PDF导出失败:' + err.message);
})
.finally(function () {
document.querySelector('.ke-button-export').classList.remove('loading');
});
}
这里提醒一句:editor.getData()拿到的是编辑器内部HTML,不等同于页面渲染后的完整结构。如果页面上有额外的CSS影响了排版,必须把这部分CSS也一并传给后端,否则PDF和页面上看到的效果很可能对不上。这是KindEditor集成里最容易忽略的一个坑。
3. 核心流程实现:从编辑器HTML到可归档PDF
3.1 第一步:清洗HTML,别把页面垃圾带进PDF
KindEditor输出的HTML通常包含大量编辑器特有的DOM痕迹:空段落、粘贴残留样式、可控编辑区的包裹div。直接把这些内容扔给PDF引擎,轻则排版松散,重则出现奇怪的空白页。所以拿到HTML后,第一件事是清洗。
清洗有个常用手段:让后端解析并重建HTML结构。Java环境下我常用jsoup来处理,它虽然不是PDF控件的一部分,但用来清洗HTML非常顺手。主要做三件事:去掉所有脚本、事件属性和无意义的空节点;把部分class样式转换成style内联样式;把img的src统一处理成可访问的URL。为什么要转内联样式?因为PDF引擎接收的是孤立HTML,它根本不会去加载你业务系统的全局CSS文件,只有写在内联样式里的规则才能被可靠渲染。
这个步骤很关键,我再强调一下:KindEditor编辑区的字号、颜色、对齐方式,大多靠CSS类实现,如果只是简单地把HTML传给转换引擎,出来的PDF可能会“素面朝天”。我的做法是在后端定义一份样式白名单,把常用的排版样式比如font-size、font-family、text-align、line-height保留下来,其余花哨样式一律清掉。这样做既保证排版还原度,又能防止用户粘贴外部内容时带进奇怪的样式干扰输出。
3.2 第二步:图片资源处理,别让PDF里的图凭空消失
图片问题在HTML转PDF里出现频率极高。KindEditor的行为会因为配置不同而分两种:图片最终以服务器路径保存,或者以base64格式直接嵌在HTML里。如果是服务器路径,相对路径转绝对路径就很重要,否则PDF引擎在服务端解析时找不到图片文件,成品里就是一块空白区域。处理方式是给img标签的src拼接上系统配置的图片域名前缀。
如果是base64图片,处理思路更直接:把base64字符串从HTML里取出来,在服务端保存成临时文件,然后把img的src替换成这个临时文件的可访问URL或者文件路径,再交给PDF引擎渲染。为什么不能直接保留base64?一是因为base64内容透明但体积膨胀严重,二是因为不少国产PDF引擎对超长src的解析支持并不好,遇到大图会直接跳过。拆出来既能减小HTML报文体积,也能减少引擎解析压力。
实际开发中我用过这样的辅助方法:
java复制private String extractBase64Images(String html, String tempDir) throws IOException {
Document doc = Jsoup.parse(html);
for (Element img : doc.select("img[src^=\"data:image\"]")) {
String dataSrc = img.attr("src");
String meta = dataSrc.substring(dataSrc.indexOf(",") + 1);
byte[] bytes = Base64.getDecoder().decode(meta);
String fileName = "img_" + System.currentTimeMillis() + "_" + new Random().nextInt(1000) + ".png";
Path path = Paths.get(tempDir, fileName);
Files.write(path, bytes);
img.attr("src", path.toAbsolutePath().toString());
}
return doc.html();
}
这里还要留意:临时文件用完要清理。如果转换服务是常驻进程,临时目录会越积越大,最后磁盘被写满。我通常配合一个简单的定时任务,定期清理超过24小时的临时文件,省心很多。
3.3 第三步:分页、页边距和页眉页脚的控制策略
HTML是连续流式布局,PDF是固定分页的文档,这中间的分页控制是转换的核心难点。国产化PDF引擎一般都会提供页面设置接口,比如页面大小、页边距、页眉页脚模板,这些参数可以在调用时直接配置。但HTML内部的分页行为,只能通过CSS来控制。
我的经验是把下面这些样式写进清洗后的HTML,而不是依赖用户手动设置:
css复制body {
font-family: "SimSun", "宋体", serif;
font-size: 12pt;
line-height: 1.6;
}
table {
page-break-inside: avoid;
border-collapse: collapse;
}
tr {
page-break-inside: avoid;
}
h1, h2, h3 {
page-break-after: avoid;
}
page-break-inside: avoid的作用是让表格尽量保持完整,不要被截断成两页;标题后面不直接断页,是为了避免出现“标题在页尾、正文在下一页”的尴尬版面。这些规则在多数国产引擎里都能识别,兼容性比你想的好。
页眉页脚方面,如果项目要求每页都带公司名称或者页码,优先用引擎本身的页眉页脚模板,而不要在HTML里用position: fixed的div去模拟。固定定位在HTML转PDF时基本不可靠,经常出现只出现在第一页或者位置错乱的情况。引擎级别的页眉页脚是真正绘制在每个PDF页面上的,效果稳定得多。
页边距也要提前跟业务确认好。归档型PDF一般用A4纵向、上下边距2厘米、左右边距2.5厘米比较稳妥;如果是用来做电子签章前置的,还要额外预留出盖章位置,这个不提前规划,后期返工成本非常高。
3.4 自动触发与批量转换的工程化设计
手动导出做好之后,自动转换其实是同一个接口的延伸。保存文档成功后自动触发转换,和手动点击的区别在于:自动触发的场景,不能让用户等待转换结果,不然接口耗时可能从几百毫秒暴涨到几秒甚至几十秒,严重影响体验。
我的做法是引入一个简单的任务表。文档保存成功后,往任务表插入一条待转换记录,后台线程池轮询处理。转换完成后把PDF路径回填到任务记录里,同时更新文档的归档状态。前端轮询查状态,转好了就提示用户可下载。这张任务表本身不需要很复杂,核心字段就这么几个:任务ID、文档ID、状态、失败原因、重试次数、创建时间。
如果是存量文档批量转换,任务表方式还能顺便承载进度统计。每处理完一条就更新状态,前端大屏或者管理页面就能实时展示“已转换 123/5000 篇”,客户看着心里踏实,也方便定位哪些文档转换失败。批量场景还有一个额外策略:可以限制并发数,比如同一时间只跑3个转换任务,避免全部任务同时打到PDF引擎上把它压垮。国产化引擎在并发度不高的时候性能挺稳定,但高并发下容易出现内存抖动,限流这个动作建议提前加上。
4. 常见问题排查与实操心得
4.1 中文乱码的排查思路
PDF打开后中文全是方块,这个问题在群里被问过无数次。我的排查顺序固定三步:先看服务器字体是否安装,用fc-list :lang=zh确认;再看引擎配置里是否注册了字体路径;最后检查HTML里font-family是否写了能映射到已注册字体的名称。三步下来,九成的乱码问题都能解决。
有一个容易被忽略的细节:部分国产PDF引擎对font-family的处理方式不是“按顺序找第一个存在的字体”,而是直接找列表第一项,如果第一项没注册,后面它根本不会继续找。所以注册字体时,我会把SimSun、宋体、SimHei、黑体这类常见名称全部映射到同一个字体文件,相当于给引擎做好了一张别名表。这样前端HTML里怎么写字体名,后端都不会找到空。
4.2 表格和长内容破页怎么处理
表格被拆成两页的问题几乎每个项目都会遇到。表现是表头在第一页,表格内容跑到第二页,中间还断在半行。处理方式我在前面已经写了CSS约束,这里补充几个实操技巧。对于特别宽的表格,可以给table设置width: 100%并且word-wrap: break-word,防止单元格文字溢出导致列宽计算错误。对于长表格,建议开启引擎的“重复表头”功能,让每页都能显示表头,否则第二页开始读者根本不知道那列数据是什么含义。
如果HTML里有用户手工调整过的多级缩进、嵌套表格,建议清洗阶段就把嵌套表格拆成扁平结构,或者限制嵌套层级不超过两层。嵌套过深的表格在PDF引擎里解析出错的概率非常高,而且很吃内存。
4.3 base64图片导致接口超时的排查
线上环境遇到过一次很典型的问题:某用户贴了一张4MB的截图,点击导出后接口一直转圈,最后超时。排查下来发现HTML报文被base64撑到接近6MB,Nginx限制卡住了上传,实际上后端根本没收到请求。解决思路有两个层面并行:前端在KindEditor粘贴图片时,就拦截上传接口把它落盘成服务器文件,编辑器里只保留图片URL路径,这样HTML里的图片引用永远是链接而不是大段二进制;后端在接口层再加一道保护,如果HTML报文超过阈值就直接拒绝并返回友好提示。两件事一起做,才能防止用户在页面里粘贴超大图片把整个流程拖垮。
4.4 水印和留痕需求的处理
公文系统转PDF,十有八九要加水印,常见的有“内部资料”“禁止外传”或者登录人姓名和工号。我踩过的坑是:一开始想在HTML里加一个半透明div来模拟水印,结果发现它只会出现在第一页,或者位置歪到不可思议。后来老老实实改用引擎提供的水印功能,通过参数设置文字内容、旋转角度、透明度、字号和间隔,效果稳定很多。
水印内容如果是动态的,比如当前操作人的工号,注意要在服务端生成时传入,而不是在前端写死。因为自动转换场景下,前端发起请求的人可能是审批操作员,但如果任务是异步队列触发,人的信息就得在创建任务时提前写入,否则转换服务根本不知道水印该写谁的名字。这个细节我曾经在联调时被测试抓出来过,算是给大家提个醒。
提示:不管用哪家国产化PDF引擎,拿到文档后一定自己打开PDF肉眼检查一遍排版。自动化测试可以保证功能不报错,但“版面是否美观”这件事,机器说了不算,只有人眼看了才算数。越是紧急上线,越要留出这轮人工抽检的时间。
最后分享一点个人体会。在遗留系统上做这种“边角功能”的国产化替换,难的不是写代码,而是把老系统的历史行为和用户习惯摸透。KindEditor本身的API并不复杂,真正花时间的全在样式兼容和场景适配这些看不见的地方。如果让我重新做一遍这个项目,我会在需求分析阶段就多问一句:“导出的PDF要用来干什么?”是给人看的,给机器归档的,还是要走电子签章的,三种场景对应的技术方案可能完全不同。把这个想清楚,后面才能少走弯路。
