做信创适配这几年,常被问到的一个问题不是“这个功能怎么做”,而是“这个功能原来在Windows上好好的,到了信创环境怎么就不行了”。富文本编辑器的PDF转存就属于典型场景。标题里说的这个富文本编辑器,圈内一般叫UEditor,很多老OA系统、内容管理平台都在用。过去要从编辑器里导出PDF,通常靠服务器上装一套Office,然后用脚本把内容喂进去转格式,或者前端插个ActiveX控件直接调客户端的软件保存。这两条路放到信创环境下基本全废:ActiveX本身就是Windows的特权,国产操作系统上不可能跑通;服务器端Office组件更是想都不要想,版权和兼容性都是坎。
这篇文章把我实测过的完整配置过程写出来,包括前端的编辑器改法、后端转换服务怎么搭、中文字体怎么处理、并发和超时怎么调,以及上线后遇到的一堆坑。内容面向的是要真正把功能落地的人,不管你是做某政务系统的对接,还是给某企业OA做国产化改造,照着我这个思路走一遍,至少能少踩几个我用三天时间才趟平的坑。
1. 先别急着配转换工具:梳理一下信创环境下的真实链路
1.1 为什么老的Word导出方案在信创环境里直接失效
很多老项目里,UEditor的打印、导出Word、导出PDF都不是编辑器自己实现的,而是靠后端写了一个接口,接收编辑器提交的HTML,然后转成一个Word或PDF文件。这个接口过去最常用的实现方式是装Microsoft Office,再用COM组件调用它的另存为功能。以我见过的一个典型老系统为例,代码里就是一段VBS脚本,打开Word文档、另存为PDF、关闭进程,动作一气呵成,表面上看起来还挺顺。
但到了信创环境,这套东西的问题就暴露得干干净净。首先是操作系统层面的变化,国产CPU加国产操作系统的组合,几乎没有Office的桌面授权可用;就算有,COM组件这套基于Windows底层组件的调用机制在Linux内核上根本不存在。很多人一开始以为只要换一个“转换软件”就行,实际上要换的是整条依赖链:编辑器不能直接生成文件,转换服务必须重新选型,字体环境还要跟着重配。
所以这个任务的本质不是“配置UEditor的某个隐藏开关”,而是把原来“编辑器+Office服务端组件+字体环境”的整条链路,替换成“编辑器+跨平台转换服务+国产系统字体环境”。把这个认知先立住,后面每一步都不会白做。
1.2 所谓的“PDF转存”,完整流程到底包含哪几段
要配置得明白,得先把一次完整的PDF转存拆开看。UEditor里用户编辑的内容,本质上是一段HTML片段,里面可能包含段落、表格、图片、字体样式。从这段HTML变成一个PDF文件,至少要经过三个环节。
第一个环节是内容采集。编辑器前端把内容以HTML字符串的形式取出来,这里有个容易被忽略的细节:如果正文里的图片是相对路径,比如/upload/xxx.png,到了转换阶段,转换引擎是不知道这个相对路径指向哪台机器的。所以前端要么把图片转成base64一起提交,要么在传给后端之前把相对路径补成完整可访问的URL。
第二个环节是服务端接收与组织。后端接口拿到HTML字符串之后,需要把它写成一个临时文件,或者包一层完整的HTML模板,再交给转换引擎处理。这个环节决定了转换出来的PDF是干干净净的正文,还是带上了编辑器的样式污染。
第三个环节才是真正的格式转换。转换引擎读取HTML或文档文件,渲染成PDF并输出。这一步的难点不在“能不能转”,而在“转出来好不好看”,中文字体缺不缺、表格边框有没有丢掉、图片是否跨域加载不出来,全都在这个环节暴露。
把这三个环节理清楚以后,方案选型就有了依据:卡脖子的不是编辑器,而是转换引擎的能力和字体环境的完整度。
2. 方案怎么选:四种PDF生成路线的横向对比
2.1 LibreOffice、无头浏览器、商用组件到底选哪个
我梳理了一下目前信创环境里实际能跑的PDF生成路线,主要就四种。第一种是LibreOffice的headless模式,也叫无头模式,就是在没有图形界面的服务器上启动LibreOffice,用命令行把Word文档、HTML文件或者ODT文件转换成PDF。第二种是无头浏览器方案,用Chromium内核跑一个后台服务,直接把页面打印成PDF。第三种是一些国产办公软件厂商提供的转换SDK或中间件,部署在服务器上,以服务方式对外提供转换能力。第四种是纯前端方案,直接在浏览器里打印,或者用JavaScript库生成PDF。
这四种方案不是简单的好与坏,而是适用场景完全不同。我画了个对比表供参考:
| 方案 | 信创可用性 | 样式还原度 | 中文字体表现 | 部署复杂度 | 主要限制 |
|---|---|---|---|---|---|
| LibeOffice headless | 较高,有国产CPU适配版本 | 对Office文档好,对复杂HTML一般 | 依赖系统字体,需单独配置 | 中,需装软件和字体 | 并发处理弱,需做进程管理 |
| 无头浏览器打印 | 高,只要Chromium能跑就行 | 高,基本等同浏览器渲染 | 同样依赖系统字体,但渲染引擎好 | 中,需维护浏览器实例 | 内存占用高,需多做参数调优 |
| 商用转换SDK | 视厂商适配情况而定 | 一般较好 | 一般无需自己处理 | 低,按文档接入 | 成本高,信创版本成熟度不一 |
| 纯前端打印方案 | 高,但依赖客户端环境 | 高,所见即所得 | 不依赖服务器字体 | 低 | 无法自动保存到服务器,交互体验碎片化 |
实际项目中,我建议不要只押一种方案。最稳的组合是:日常富文本内容的PDF导出,走无头浏览器打印,因为UEditor产出的就是HTML,浏览器渲染HTML本来就是本职工作,样式还原度最高;而用户上传的Word、Excel这类附件要转PDF时,再走LibreOffice的转换服务兜底。
2.2 为什么我把“无头浏览器”当主选项而不是LibreOffice
最初我也图省事,想着服务器上装个LibreOffice,一行命令就能把HTML转成PDF。但实测下来发现,LibreOffice对HTML的渲染能力其实挺“复古”的。UEditor编辑出的内容里有大量inline样式,比如浮动布局、背景色、边框、缩进,这些在现代浏览器里渲染毫无压力,但LibreOffice处理起来经常会出现表格边框丢失、背景色消失、段落间距变样的问题。改CSS的兼容成本很高,还不一定彻底。
无头浏览器方案则是直接调用Chromium的打印接口,把HTML字符串包裹成完整页面,然后设置打印选项,输出的PDF和用户在编辑器里看到的样子基本一致。这个方案对CSS的兼容能力是碾压级的,而且中文字体渲染也更细腻,不会出现笔画发虚的问题。
当然,无头浏览器也不是没有代价。它需要一个常驻的Chromium进程,内存占用比LibreOffice大不少,转换频繁的时候还需要做加载队列和超时保护。但从“还原度”和“可控性”两个角度衡量,对于富文本内容转PDF这个场景,它确实是最合适的选项。
2.3 容器化部署好,还是直接在物理机上装更省心
信创环境还有一个躲不开的问题:部署形态。有些项目要求全部组件容器化,平台侧的规范就是“能进容器就不能裸装”。如果你所在的信创平台提供的是ARM架构或某国产CPU架构的容器环境,直接拉一个通用的Chromium镜像通常会失败,因为镜像里没有适配对应指令集的二进制。这时候要做的是找一个基础系统镜像,自己装Chromium或LibreOffice,再封装成私有镜像。这条流程并不复杂,但需要预留时间做镜像构建和验证。
如果项目允许,我反而更推荐先用物理机裸装的方式跑通功能,再考虑容器化。原因很简单:字体、临时目录、进程管理在裸机上排查起来直观得多,等整个转换链路稳定了,再把它封装进镜像,踩坑成本最低。我一个项目里吃过亏,直接用别人给的容器镜像,结果里面的中文字体根本没装,转换出来全是方块,排查半天才发现镜像本身有问题。
3. 动手配置:编辑器、转换服务、字体三板斧
3.1 UEditor工具栏和导出接口的前端改造
操作上第一步,给UEditor加一个“导出PDF”的按钮。以UEditor的常规配置方式,在初始化编辑器时配置toolbars数组,添加一个自定义按钮名,然后用registerButton或者直接通过iframe内部按钮机制去绑定点击事件。具体按钮的UI细节各个版本有点差异,核心逻辑是拿到编辑器的内容实例,调用editor.getContent(),把HTML取出来交给后端。
这里要特别提醒两个地方。第一是图片地址处理。getContent()返回的HTML里,图片标签的src很多时候是相对路径,比如/upload/2024/xx.png。如果后端和前端部署在同一台机器同一个域名下,直接传后端也许没问题;但只要前端域名和后端转换服务域名不一致,或者中间有负载均衡、反向代理,图片链接就会失效。建议在前端先把相对路径统一替换成完整的URL,或者更省心的做法是把图片异步转成base64再拼进HTML。后一种做法唯一要留意的是大图会把请求体撑大,需要加大后端接口的接收限制。
第二是安全性的处理。传给后端的内容是HTML片段,后端接口不能直接把它当作可信数据存库或交给转换引擎渲染,必须做一层白名单过滤,把script、iframe、object这类标签去掉,否则转换服务很容易变成存储型脚本的攻击入口。这一步不是可选项,是必选。转换引擎本身无感,但它解析HTML时把恶意脚本当普通文本处理了,问题会被放大到所有看过这个PDF的人身上。
前端最终发的请求类似这样:
function exportPdf() { let content = editor.getContent(); // 把图片相对路径补成绝对路径的代码省略,实际项目中必须做 fetch('/api/editor/export-pdf', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ html: content, title: editor.getContentTxt().slice(0, 50) }) }).then(res => res.json()) .then(data => { if (data.url) { // 跳转下载或新窗口预览 window.location.href = data.url; } }); }3.2 服务端转换接口的核心实现
后端接口的逻辑其实不算复杂,关键是要把“接收HTML”和“实际转换”两个动作解耦。我习惯的做法是把任务拆成两步:先接收前端传过来的HTML,组装成完整的HTML文件存在临时目录,同时生成一个任务ID;再把这个任务ID放进队列,返回给前端一个状态接口的地址,前端轮询或等待回调。
如果项目要求比较简单,也可以做成同步接口,前端一直等待转换完成拿到下载链接。但同步接口有一个非常现实的坑:转换一个几十页的文档,无头浏览器或者LibreOffice跑下来少则两三秒,多则十几秒,前端请求很容易超时,网关层的超时时间默认往往只有60秒。所以我建议至少做成“接口返回任务ID + 前端轮询结果”的模式,转换完成的文件用独立下载接口提供。
下面是一段转换服务伪代码,以Java为例,展示核心流程:
public Map<String, String> exportPdf(String htmlContent) { String taskId = UUID.randomUUID().toString(); String tempPath = "/data/convert-temp/" + taskId; // 1. 把HTML片段包成完整页面 String fullHtml = "<html><head><meta charset='utf-8'>" + "<link rel='stylesheet' href='/css/editor-content.css'>" + "</head><body>" + htmlContent + "</body></html>"; FileUtil.write(fullHtml, tempPath + "/input.html"); // 2. 调用转换工具,比如Chromium headless打印PDF String command = String.format( "chromium --headless --no-sandbox --disable-gpu " + "--print-to-pdf=%s --no-pdf-header-footer %s", tempPath + "/output.pdf", tempPath + "/input.html" ); Process process = Runtime.getRuntime().exec(command); boolean done = process.waitFor(60, TimeUnit.SECONDS); if (!done) { process.destroyForcibly(); throw new RuntimeException("PDF转换超时"); } // 3. 返回下载地址,由另一个接口负责输出文件流 return Map.of("url", "/download-pdf?taskId=" + taskId); }实际项目里比这段复杂的地方在于并发控制、进程回收、临时文件清理。后面专门说。
3.3 中文字体安装与字体映射(最容易翻车的一步)
信创环境下转换PDF,十次有八次翻车都翻在字体上。表现也很典型:转换出来的PDF里面中文全部变成方块、变成空心框,或者英文正常中文消失。原因是服务器操作系统里没有安装中文字体,转换引擎不知道用什么字形去渲染中文字符。
解决办法也不难。第一步是确认系统里都有哪些字体,用命令查一下:fc-list :lang=zh。如果输出为空,说明一个中文字体都没有,必须装。选择上,文泉驿正黑、Noto Sans CJK SC、Noto Serif CJK SC这些都是开源中文字体,信创环境里可以直接用。安装方式以某国产Linux发行版为例,通常是yum install或者apt install对应的字体包,装上以后执行fc-cache -fv刷新字体缓存。
第二步要处理的是字体名映射。很多老系统里的HTML模板直接写了“宋体”“SimSun”“微软雅黑”这样的字体名,但开源中文字体显然没有这些名字。转换引擎在渲染时找不到对应字形,就会用默认字体替代,一旦默认字体不含中文,就出方块。我的经验是直接修改fontconfig的字体映射配置,在/etc/fonts/local.conf里把常见的中文字体名映射到实际安装的字体上。配置示例:
<?xml version="1.0"?> <!DOCTYPE fontconfig SYSTEM "fonts.dtd"> <fontconfig> <match target="pattern"> <test name="family"><string>SimSun</string></test> <edit name="family" mode="prepend" binding="strong"> <string>Noto Serif CJK SC</string> </edit> </match> <match target="pattern"> <test name="family"><string>微软雅黑</string></test> <edit name="family" mode="prepend" binding="strong"> <string>Noto Sans CJK SC</string> </edit> </match> </fontconfig>改完配置后,建议重启转换服务再试。这个过程在容器镜像里也要做一遍,很多“为什么在开发机上是好的,进容器就不行”的诡异问题,最后都查到了镜像里没有执行字体安装这一步。
3.4 超时、并发和临时文件的参数调优
转换服务上线前,有三组参数必须先想清楚。第一组是超时控制。Chromium headless在特殊页面下偶尔会卡住不退出,LibreOffice处理损坏文档也可能挂住。所以每次调用转换进程都必须设置超时时间,我一般给60秒,超时直接杀掉进程,并把任务标记为失败,返回给用户时提示重试而不是永远转圈。超时时间别设太长,否则排队一多,后面的任务全部堆住。
第二组是并发控制。无头浏览器的内存占用大概在150MB到300MB左右,LibreOffice进程约100MB上下。一台4核8G的机器,并发开到4就已经偏高了,建议默认开2到3个并发放到队列里。很多人为了追求快,把并发调大,结果内存耗尽系统开始疯狂换页,单次转换时间反而飙升。我实测过,并发从2提到4,吞吐量几乎没有提升,响应时间倒是翻了一倍。所以不要让所有任务同时跑,做一个有界队列比什么都重要。
第三组是临时文件清理。每一次转换都会生成一个临时目录,里面放着输入HTML、中间文件和输出PDF。如果不定期清理,磁盘会被撑爆,整个转换接口直接报错。清理策略我建议是“转换完成后立即删除输入文件,PDF文件保留24小时,超时任务和异常任务的临时目录每小时巡检清理一次”。
4. 实战中的坑:排查思路与修复记录
4.1 转换出来的PDF全是方块字,第一步先查什么
这个现象应该排在我遇到问题榜第一名。排查顺序很重要,别一上来就去看代码。先在服务器上手动执行一次转换命令,把HTML文件直接交给转换引擎,看看输出PDF是否正常。如果手动转换就是方块,那问题几乎必定出在系统字体层面,按上一节说的装字体、刷新缓存、改映射即可。如果手动转换正常,前端调用才异常,那问题就变成了Web服务进程的字体环境差异,很可能是转换服务的运行用户或容器里缺少字体,排查方向就要转向“服务进程到底用的是哪一套字体环境”。
有一个容易被忽视的细节:通过SSH手动执行转换时,用的是当前SSH用户的字体配置;而Web服务进程可能是nobody用户或容器内的独立用户。这两个环境的字体列表完全可能不一样。出现“命令行正常、接口异常”的情况时,优先去查Web服务进程的执行用户和它对应的字体缓存。
4.2 转换进程假死,端口被占用但不报错
无头浏览器方案里,另一个高频问题就是进程假死。表现是转换请求一直没响应,服务器上ps能看到一个Chromium进程,CPU占用极低,但它就是不退出。比较常见的原因是无头浏览器在加载页面时遇到异常资源或弹窗开始等待。解决办法就是在命令参数里显式设置--disable-features=VizDisplayCompositor、--run-all-compositor-stages-before-draw这类参数,以及最重要的--no-sandbox(容器内运行时必须加)。如果是在容器里跑,--no-sandbox不加几乎必报错。
另外,命令进程超时后,要确保整个进程组被杀死,而不只是父进程。Java的destroyForcibly()默认只杀父进程,Chromium的子进程残留下来会继续占用内存,日积月累机器就卡死了。稳妥做法是启动进程时设置独立的进程组,或者在获取进程对象时调用Linux的kill命令强制杀掉整个进程树。
4.3 并发转换时的文件锁冲突
用LibreOffice方案时,并发问题尤其明显。两个转换任务同时跑,很可能有一个任务报错说profile目录被锁。这是因为LibreOffice默认会把用户配置写到同一个目录,并发启动时互相抢占。解决方案是给每个转换任务指定独立的-env:UserInstallation参数,让每个进程用自己的临时配置目录。
无头浏览器虽然没有这种锁问题,但有类似的隐性问题:如果所有转换请求复用同一个Chromium实例,页面状态会互相污染;如果每个请求都启动新实例,启动开销又太大。我的做法是维护一个小型实例池,每个实例处理一个任务后销毁,任务排队进入空闲实例,池大小再配合上层的并发队列来控制。
4.4 PDF样式和编辑器里看到的完全不一样
这也是被问得很多的问题。编辑器里看到的是一个使用编辑器自身CSS渲染过的页面,而服务器转换时如果只用裸的HTML片段,很多样式自然就丢了。比如UEditor的内容区域依赖一套editor.css,里面有.ql-editor、.content-body之类的样式类,直接拿getContent()的HTML去转PDF,那些样式类对应的外观全部失效。
解决办法是转换时把编辑器内容区的CSS一起注入到HTML模板里。前期配置时,把UEditor用到的样式文件复制一份到服务端可访问的目录,转换前拼进<head>。更精细的做法是单独维护一套针对打印的CSS,专门定义正文标签在PDF里的排版:标题字号、段落行距、表格边框、页码边距,都在这套CSS里控制。这样做的好处是PDF的样式不再“继承”编辑器的界面样式,而是有一个独立、稳定的打印样式,无论编辑器怎么改版,PDF输出都不受影响。
5. 性能表现与上线后的日常维护
5.1 实测数据:转换耗时与资源占用
我在一台4核8G的测试服务器上(某国产化CPU环境)做过一组数据观测。单页纯文本内容转PDF,无头浏览器方案耗时在2秒左右,LibreOffice方案约3秒;一个包含10张图片和表格的10页内容,无头浏览器大概6到8秒,LibreOffice大约10到12秒。内存占用方面,无头浏览器在转换高峰期能到900MB,平时保持空闲实例池常驻内存大概300MB;LibreOffice每个转换进程占用大约150MB,任务结束即释放。
这里要特别说一个“优化陷阱”。很多人一开始会想着用缓存:同一个用户在同一篇文章反复转PDF,是不是可以打缓存?理论上可以,但实际操作中,编辑器的内容变化很频繁,缓存命中率不高,反而要花精力做失效管理。我倾向于只在“同一任务ID重复下载”这个层面做文件保留,不提前做内容级缓存,等功能稳定跑一段时间,根据实际命中率再决定要不要加。
5.2 运维监控里必须盯的四个指标
转换服务上线后,监控比功能本身更重要。我整理了一个最小化的监控清单:
- 单次转换耗时和90分位耗时。耗时突然上涨,优先查系统字体缓存、临时目录磁盘占用和并发排队长度。
- 转换失败率和失败原因分类。字体缺失、超时、进程崩溃这三类原因的占比变化,能提前暴露环境变更或流量异常。
- 临时目录磁盘占用。这个指标最容易爆,建议每小时统计一次,超过阈值自动清理并告警。
- 转换服务进程数和僵尸进程数。如果进程数长期高于设置的并发上限,说明任务堆积或者超时回收逻辑出了问题。
日志方面,我习惯在转换服务里打印“开始转换、HTML组装完成、引擎启动、渲染完成、文件已生成”这五个关键节点的耗时。这样一旦用户反馈“转PDF很慢”,翻日志就能定位到是卡的哪一段,而不是对着浏览器黑盒干猜。
5.3 后续还能扩展什么
整套链路跑通以后,能扩展的方向其实不止一个。最自然的方向是做在线预览:既然PDF已经生成,顺带提供一个在线预览页面,用户点导出之前可以先看一眼效果。另一个方向是水印需求,信创项目中对文档水印的需求特别常见,可以在PDF生成后调一个水印接口处理,或者直接在打印样式里把水印文字注入到页面背景里,让PDF天生带水印。
还有批量导出。内容管理平台经常需要把一批文章的正文合并导出成一个PDF,这个只需要把多个HTML片段按顺序拼成一个完整页面,交给无头浏览器一次打印即可。需要留意的是目录页码和分页符,根目录样式里用CSS的page-break-before控制每个章节从新页开始。
最后想提醒一点:随信创环境的迭代,国产浏览器和国产操作系统的打印接口也在不断变化。这套方案要留出足够的抽象层,前端调用、转换服务、字体配置都要单独拆模块,以后哪一层换了实现,不至于动一发牵全身。
我个人实际做下来最大的体会是:信创环境下的技术难点往往不在功能本身,而在你对运行环境的前置假设是否正确。别把Windows生态的经验直接搬过去,先花时间确认字体、进程权限、指令集架构这些看似基础的东西,后面反而顺畅得多。希望这篇记录能让你少走几个弯路。