ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

军工OA系统中CKEditor配置PDF转存方案与踩坑实践

军工OA系统中CKEditor配置PDF转存方案与踩坑实践 军工行业OA系统如何配置CKEditor的PDF转存功能先说明白一个场景你在一家军工单位的OA系统里领导要求写份报告编辑器用的是CKEditor正文填完了得输出一份固定版式的PDF带编号、带水印、带页码还不能直接把HTML打印成乱七八糟的样式。这个需求在普通企业里可能十分钟就搞定了但在军工内网这种网络隔离、国产化栈、保密审计多重要求叠加的环境里每一步都有讲究。我当年接手这个功能的时候翻遍了网上能搜到的教程大部分都是互联网公司那种“装个插件调个云接口就完事”的思路放到内网环境里根本不适用。折腾了两周踩了一堆坑最后总结出一套比较稳妥的配置方案。这篇文章就围绕“CKEditor怎么配置PDF转存”这件事展开讲讲方案选型、具体配置、安全适配和踩坑记录给正在做同样事情的朋友一个参考。这个需求适合谁看如果你是OA系统运维、集成开发工程师或者正在做信创/国产化办公系统改造这篇文章的内容可以直接复用。哪怕你不是军工行业只要你的系统跑在隔离内网里不能依赖外部网络服务这套思路同样成立。1. 先搞清楚军工内网做PDF转存难在哪1.1 为什么不能直接装CKEditor插件CKEditor从5.0版本开始官方把PDF导出功能做成了云服务需要联网获取license token编辑器调用外部接口渲染PDF。这在公网项目里很省事但在军工内网就是一道死线——我们的网络环境是物理隔离的任何对外部服务的依赖都无法通过安全评审而且也不允许把文档内容回传到任何第三方服务器。CKEditor 4的exportpdf插件早期版本还支持配置本地导出服务地址但后续维护跟不上很多接口也已经失效。就算你翻出老版本插件它也依赖你的系统能访问某些公共资源在隔离内网里一样跑不通。所以军工环境下的第一原则是不能用任何需要外网、云服务、license验证的现成方案所有能力必须落到自己内网的服务器上。1.2 军工场景的三个硬约束做这个功能前建议先摸清你所在环境的约束条件这些条件决定了方案选型网络隔离约束。系统运行在涉密内网或非涉密内网中部署环境无法访问互联网所有依赖必须离线安装。这就意味着你需要把转换引擎、字体、依赖库全部在内网源准备好。国产化栈约束。服务器可能是麒麟、统信等国产操作系统CPU可能是飞腾、鲲鹏、龙芯这些ARM或自主架构。很多开源组件在x86下跑得好好的换到ARM架构上就需要重新编译或者根本找不到现成的二进制包。安全审计约束。文档导出属于敏感操作必须留痕。谁在什么时间导出了哪篇文档、文档编号是多少、文件大小多大这些都要记录。PDF本身还要能追溯来源一般要求带水印、带文档标识一旦泄密能追踪到源头。这三点是军工OA里做任何文档处理功能都必须提前想清楚的别等代码写完了再回头补那是灾难。2. 方案选型PDF转存到底走哪条路2.1 三种常见方案的对比我梳理了军工内网里可行的三种方案分别是前端打印方案、纯服务端转换方案、混合渲染方案它们的优缺点对比如下方案实现方式优点缺点前端打印方案用window.print()调起浏览器打印用户手动选择“另存为PDF”实现最快无需额外依赖版式不可控每台机器打印效果不一样水印页码难以统一还依赖客户端环境纯服务端转换方案后端接收HTML用转换引擎生成PDF直接下发版式统一、可批量处理、水印和字体可控、适合审计留痕需要额外部署转换服务对HTML/CSS兼容性有要求混合渲染方案前端无头浏览器截图或JS渲染后再转PDF能处理复杂前端页面资源消耗大、并发能力差、实现复杂度高军工场景里由于对版式统一性、水印固定、审计留痕有硬性要求前端打印方案基本pass掉。混合渲染方案适合那种页面交互特别复杂的场景比如地图、图表报表但如果只是把编辑器里的公文转成固定版式PDF纯服务端转换方案是性价比最高的。2.2 为什么我推荐服务端HTML转PDF服务端转换的核心思路是CKEditor负责编辑前端把编辑器的HTML内容提交给后端后端把这段HTML嵌入到一个固定模板中模板里写死公文版式、页眉页脚、水印、字体然后用转换引擎把完整HTML渲染成PDF返回。这样做的优势有三点第一版本一致。PDF在服务器上生成不受用户本地浏览器、操作系统、字体环境影响所有人生成出来的文件格式完全一致。第二审计可控。转换动作发生在服务端可以拦截、可以鉴权、可以记录。前端只要一调接口后台就能记录操作人、IP、文档编号。这种能力在军工环境里不是加分项是必选项。第三样式可定制。公文往往要求固定版式比如标题用方正小标宋正文用仿宋GB2312段落首行缩进两个字符这些都可以在模板里用CSS控制比前端打印稳定得多。转换引擎方面我用过wkhtmltopdf和headless Chromium前者轻量对老系统兼容好后者对CSS3、Flexbox的支持更好但吃内存。如果是普通公文wkhtmltopdf完全够用而且离线部署简单一个二进制文件就能跑。如果你所在的环境里HTML排版需求复杂比如要呈现表格、流程图、特殊样式优先考虑headless Chromium。提示无论选哪个引擎都要确保服务器已经离线安装了所有依赖。wkhtmltopdf在Linux上依赖X11相关库文件headless Chromium则依赖一堆系统库建议在部署环境里做好依赖备份别等上线了才发现装不上。3. 核心实现CKEditor配置与PDF导出接口3.1 CKEditor工具栏自定义配置在诉求是“转存PDF”的前提下前端最合适的做法是在CKEditor工具栏上加一个“导出PDF”按钮用户编辑完正文后一键触发。以CKEditor 4为例你可以用自定义插件的方式注册一个按钮点击后把内容提交到后端。先看CKEditor的初始化配置CKEDITOR.replace(content, { height: 500px, toolbar: [ { name: document, items: [Source, -, Save, NewPage] }, { name: fonts, items: [Font, FontSize, Bold, Italic] }, { name: paragraph, items: [JustifyLeft, JustifyCenter, JustifyRight, JustifyBlock] }, { name: insert, items: [Image, Table, HorizontalRule] }, { name: tools, items: [ExportPdf] } ], extraPlugins: exportpdf, removePlugins: exportpdf_cloud, language: zh-cn, font_names: 仿宋_GB2312;方正小标宋简体;黑体;楷体_GB2312;Arial; });注意这里的重点去掉官方云插件自己写一个同名的本地按钮插件。插件代码很简单核心就是点击后拿到编辑器内容异步提交给后端导出接口CKEDITOR.plugins.add(exportpdf, { init: function(editor) { var btn editor.ui.addButton(ExportPdf, { label: 导出PDF, command: exportPdf, toolbar: tools, icon: this.path icons/pdf.png }); editor.addCommand(exportPdf, { exec: function() { var content editor.getData(); var title editor.document.$.title || 未命名文档; // 提交到后端接口 $.ajax({ url: /oa/api/editor/exportPdf, type: POST, contentType: application/json, data: JSON.stringify({ title: title, content: content, docNo: $(#docNo).val(), bizType: report }), success: function(result) { if (result.code 0) { window.open(/oa/api/editor/downloadPdf?fileId result.data.fileId); } else { alert(导出失败 result.msg); } } }); } }); } });这段代码里有个细节我故意把用户可编辑的文档编号docNo也提交上来了。军工单位的公文通常有编号PDF里要体现这个编号同时留档审计时也能按文档编号检索。3.2 后端接口设计与模板组装后端接口我用Java Spring Boot实现核心逻辑分五步权限校验、内容清洗、模板组装、转换引擎调用、审计留痕。下面是一个接口骨架PostMapping(/api/editor/exportPdf) ResponseBody public MapString, Object exportPdf(RequestBody ExportRequest request, HttpServletRequest httpRequest) { // 1. 权限校验当前登录用户是否有导出该文档的权限 User currentUser SecurityUtils.getCurrentUser(); if (currentUser null) { return ResultUtil.error(未登录或会话过期); } boolean hasPermission permissionService.checkExportPermission( currentUser.getId(), request.getBizType(), request.getDocNo()); if (!hasPermission) { auditService.log(currentUser, EXPORT_PDF_DENIED, request); return ResultUtil.error(当前账号无导出权限); } // 2. 内容清洗滤掉潜在XSS和非法标签 String safeContent XssFilter.clean(request.getContent()); // 3. 模板组装 String fullHtml buildPdfTemplate(safeContent, request); // 4. 调用转换引擎 File pdfFile htmlToPdf(fullHtml, request.getDocNo()); // 5. 审计留痕 auditService.log(currentUser, EXPORT_PDF, request.getDocNo(), pdfFile.length(), request.getIp()); return ResultUtil.success(Collections.singletonMap(fileId, pdfFile.getName())); }这里的buildPdfTemplate方法很关键。你不能直接把编辑器里的HTML不经处理就丢给转换引擎因为编辑器里的HTML往往有很多内联样式、无意义的div嵌套直接转出来样式会乱。正确做法是组装一个完整的、版式固定的HTML文档private String buildPdfTemplate(String content, ExportRequest request) { StringBuilder sb new StringBuilder(); sb.append(!DOCTYPE html); sb.append(htmlhead); sb.append(meta charset\UTF-8\); sb.append(style); sb.append(body { font-family: 仿宋_GB2312, FangSong, serif; font-size: 16pt; line-height: 1.6; margin: 0; }); sb.append(.doc-title { font-family: 方正小标宋简体, serif; font-size: 22pt; text-align: center; font-weight: bold; margin-bottom: 20px; }); sb.append(.doc-no { text-align: center; font-family: 仿宋_GB2312; font-size: 14pt; margin-bottom: 10px; }); sb.append(p { text-indent: 2em; margin: 0 0 8px 0; }); sb.append(table { border-collapse: collapse; width: 100%; }); sb.append(td, th { border: 1px solid #000; padding: 6px; }); sb.append(/style/head); sb.append(body); sb.append(div class\doc-title\).append(escapeHtml(request.getTitle())).append(/div); sb.append(div class\doc-no\).append(escapeHtml(request.getDocNo())).append(/div); sb.append(hr style\border: 1px solid #000; margin: 10px 0;\); sb.append(div class\doc-content\).append(content).append(/div); sb.append(/body/html); return sb.toString(); }这里注意一个细节title和docNo是从参数传入的必须做HTML转义防止参数注入content是用户编辑的富文本清洗后直接嵌入因为清洗过一遍不会带脚本标签。3.3 转换引擎调用与参数细节模板组装好之后调用wkhtmltopdf进行转换。我用的是Java调用系统的ProcessBuilder没有用第三方封装库这样可控性更强private File htmlToPdf(String fullHtml, String docNo) throws IOException, InterruptedException { Path tempDir Files.createTempDirectory(pdf_export_); Path htmlFile tempDir.resolve(docNo .html); Path pdfFile tempDir.resolve(docNo .pdf); Files.write(htmlFile, fullHtml.getBytes(StandardCharsets.UTF_8)); String cmd /usr/local/bin/wkhtmltopdf --enable-local-file-access --page-size A4 --margin-top 25mm --margin-bottom 25mm --margin-left 25mm --margin-right 20mm --footer-center \第 [page] 页 / 共 [topage] 页\ --footer-font-name \仿宋_GB2312\ --footer-font-size 9 --encoding utf-8 htmlFile.toAbsolutePath() pdfFile.toAbsolutePath(); Process process Runtime.getRuntime().exec(cmd); boolean finished process.waitFor(30, TimeUnit.SECONDS); if (!finished) { process.destroyForcibly(); throw new IOException(PDF转换超时); } if (process.exitValue() ! 0) { throw new IOException(wkhtmltopdf转换失败退出码: process.exitValue()); } return pdfFile.toFile(); }这些参数里有几个是我反复测试后固定下来的页边距上25mm、下25mm、左25mm、右20mm这是公文版式的常用参数页脚用仿宋9号字体、居中标页码符合公文排版习惯--enable-local-file-access是为了让模板里可以访问内网图片资源但如果你的图片都在OA的附件服务器上这个参数不一定要加加了就相当于允许引擎读取本机文件有安全风险。这个后面在安全部分细说。实战经验wkhtmltopdf对老版本HTML支持得很好但遇到CSS3的flex布局、grid布局会渲染错乱。在模板里尽量用table布局和普通divfloat布局不要用现代布局否则转出来的PDF很可能排版崩掉。如果内容必须用到复杂布局直接换headless Chromium。4. 军工环境的特殊处理字体、水印与审计4.1 字体适配是最大的坑军工公文有自己的字体规范比如标题用方正小标宋正文用仿宋GB2312二级标题用黑体。但这些字体在服务器上默认是不存在的转换引擎找不到字体就会用默认字体替代PDF里的版面就会变形甚至中文直接变成方块。正确做法是把字体文件提前上传到服务器并注册# 创建字体目录 mkdir -p /usr/share/fonts/chinese cd /usr/share/fonts/chinese # 上传以下字体文件 # 仿宋_GB2312.ttf # 方正小标宋简体.ttf # 黑体.ttf # 楷体_GB2312.ttf # 更新字体缓存 fc-cache -fv # 验证字体是否生效 fc-list | grep -i 仿宋 fc-list | grep -i 小标宋字体权限和缓存这事儿我踩过好几次坑。上传字体后一定要执行fc-cache -fv刷新缓存否则系统识别不到新字体。另外有些字体文件有授权限制不能随便用于内网分发采购授权时要确认清楚。如果转换引擎在渲染时仍然报字体找不到可以检查wkhtmltopdf的字体路径配置或者试试在系统环境变量里加上FONTCONFIG_FILE指向字体配置文件export FONTCONFIG_FILE/etc/fonts/fonts.conf4.2 水印与溯源信息PDF水印在军工场景里不只是做个“内部资料”的浅浅背景而是要把可追踪信息嵌入进去。我们当时的要求是每个PDF右下角固定区域显示“本文档由XX用户在YYYY-MM-DD HH:mm:ss导出文档编号XXX”这样一旦有人把PDF泄露出去了通过水印就能直接定位到责任人和操作时间。水印有两种实现方案我建议用第二种方案一是模板里加CSS样式比如position:fixed的div带透明度浮在内容上。这种方式实现简单但有被覆盖的风险而且wkhtmltopdf对position:fixed的支持并不完美不同版本表现不一样。方案二是PDF转换完成后再后处理用iText或Apache PDFBox往PDF上加文字水印。这种方案更可靠水印是实实在在刻在PDF内容上的不受CSS渲染影响。代码大概这样// 使用iText 7 PdfReader reader new PdfReader(srcFilePath); PdfWriter writer new PdfWriter(destFilePath); PdfDocument pdfDoc new PdfDocument(reader, writer); int n pdfDoc.getNumberOfPages(); for (int i 1; i n; i) { PdfPage page pdfDoc.getPage(i); PdfCanvas canvas new PdfCanvas(page); canvas.saveState(); float x page.getPageSize().getWidth() - 150; float y page.getPageSize().getHeight() - 40; PdfFont font PdfFontFactory.createFont(STSong-Light, UniGB-UCS2-H); canvas.setFontAndSize(font, 9); canvas.beginText().setFillColor(ColorConstants.GRAY).moveText(x, y) .showText(文档编号: docNo 导出人: userName) .endText(); canvas.restoreState(); } pdfDoc.close();这个后处理方案唯一要注意的是字体iText里默认的中文字体是STSong-Light如果服务器字体库里没有这个字体名需要改成你实际安装的字体文件路径。4.3 审计和权限的一点建议军工场景的审计是刚性的不能只靠日志文件。我当时的做法是单独建了一张审计表记录操作人ID、操作人姓名、所属部门、操作时间、IP、文档编号、文档标题、业务类型、导出的PDF文件路径、文件大小、操作结果。导出PDF成功要记录导出失败也要记录被权限拦截的请求更要记录。权限校验方面不能只看“登录用户”还要校验这个用户对当前文档有无可见和导出权限。军工系统里一份文档可能涉密级别较高不是所有人都有权导出的。这里建议在导出接口里做一个双重的校验第一道当前用户是否有导出PDF这个通用功能的权限第二道当前用户是否对该文档按bizTypebizId或docNo定位有导出权限这道权限校验不能依赖前端一定是后端的强制校验因为接口是可以被直接调用的不校验等于裸奔。5. 实际踩坑记录这些问题我几乎都遇到过5.1 转换超时和进程僵死wkhtmltopdf在处理超长文档时经常出现几十秒不返回的情况尤其当正文里有大量图片时特别明显。我遇到过进程卡死、CPU飙升、服务器上残留一堆孤儿进程的情况。解决办法是在调用层加超时控制超时后直接杀掉进程Process process Runtime.getRuntime().exec(cmd); boolean finished process.waitFor(30, TimeUnit.SECONDS); if (!finished) { process.destroyForcibly(); // 强杀 process.waitFor(); throw new IOException(PDF转换超时); }另外建议在服务器上写好定时清理脚本定期清理/tmp/pdf_export_目录下的临时文件防止临时文件越堆越多把磁盘占满。我吃过这个亏一个大磁盘被临时HTML文件填满数据库直接无法写入那次教训够疼的。5.2 wkhtmltopdf在ARM架构上装不上这事儿在国产化环境里非常普遍。x86环境下wkhtmltopdf有现成的rpm包但到了飞腾、鲲鹏这些ARM架构的国产服务器上官方不提供编译好的包自己编译又很折腾。我当时处理的办法是换用headless Chromium。Chromium对ARM的支持相对更好而且大多数Linux发行版都有对应的源码或二进制包。命令行参数类似chromium --headless --disable-gpu \ --print-to-pdf/output.pdf \ --no-sandbox \ /input.html注意headless Chromium需要--no-sandbox参数才能以root身份在服务器上运行但这会降低安全等级生产环境建议用专用低权限用户来跑别直接用root。5.3 编辑器内容里的图片显示不出来这是最隐蔽的一个坑。CKEditor里插入的图片在编辑状态下显示正常但PDF导出时图片要么裂了要么直接空白。原因在于编辑器会把图片保存为相对路径比如/oa/upload/2024/xxx.pngwkhtmltopdf在服务器上找这个相对路径找不到于是图片就丢了。解决方法是后端在模板组装时把内容的图片src路径统一替换为绝对路径。内网环境下图片地址通常是OA系统的内网域名或IP加上图片路径content content.replaceAll(src\/oa/upload/, src\http://oa-internal.example.com/oa/upload/);这个替换要小心只替换图片资源路径不要替换超链接地址。如果图片在编辑时是以Base64存储的这种也比较常见那就不需要替换直接可以正常渲染。5.4 导出内容的XSS风险CKEditor本身是有XSS过滤机制的但服务端不能因此就放松警惕。用户可以通过抓包直接构造请求往content字段里塞script标签、iframe标签、object标签这些恶意内容一旦被模板渲染出来轻则空白页重则可能被用作钓鱼或数据篡改。我建议服务端做一个白名单过滤。不是黑名单是白名单——只允许p、div、span、img、table、tr、td、th、ul、ol、li、h1-h6、br、hr、strong、em、u、s这些标签及对应属性。其他一律剥掉尤其是script、iframe、object、embed、style、link这些标签直接删除。如果用的是Spring Boot可以写个简单的过滤器或者用jsoup来做清洗Whitelist whitelist Whitelist.relaxed() .addTags(h1, h2, h3, h4, h5, h6) .removeTags(script, iframe, object, embed, link, style) .addAttributes(img, src, width, height); String safeContent Jsoup.clean(request.getContent(), whitelist);5.5 与泛微OA等系统集成时的特殊情况如果你们单位用的是泛微e9或类似平台集成时有个比较特殊的点泛微编辑器里的内容不只是HTML还会带一些泛微自身的控件标签或样式直接取出来转PDF经常会版式异常。我当时处理泛微e9的经验是不要直接从编辑器拿HTML尽量通过泛微的后端接口拿到表单数据然后自己重新组装模板。如果必须直接用编辑器内容建议先跑一遍HTML清理和样式重置把泛微自带的那套行内样式去掉再套自己的PDF模板。另外泛微本身也有文档转换组件如果只是做附件转PDF可以考虑直接用泛微自带的转换能力不必再单独搭服务。但如果是要把在线编辑的富文本内容固定版式导出还是自建转换服务更可控。6. 一些总结也是经验这套方案我在多个项目里落地过整体跑下来比较稳定。最后分享几个实际操作中的体会第一先在测试机用最小demo打通链路再嵌入OA系统。我习惯的做法是写好HTML模板拿一段测试内容先用命令行跑一遍wkhtmltopdf确认版式、字体、水印都OK了再写Java代码调用这样排查问题效率高很多。直接一上来就调接口出了问题很难定位是前端问题、接口问题还是转换引擎问题。第二转换服务单独部署别往业务应用里塞。给PDF转换单独准备一台服务器或虚拟机防止大量导出请求拖垮OA主业务。转换是非常吃CPU和内存的操作想象一下一个日报模板撑死一页A4纸但如果有用户导出一份带100张截图的验收报告转换引擎内存瞬间能飙上去。第三转换结果做缓存相同的文档不要重复生成。我们的做法是以文档编号最后修改时间作为缓存key如果文档没有修改过直接返回已生成的PDF。这不仅能节省服务器资源还能保证同一份文档在不同时间导出的PDF完全一致这在报审材料里很重要。军工行业做OA的PDF转存技术上难度不算大麻烦的是各种环境适配和合规要求叠加。希望这篇文章能帮你在配置CKEditor的PDF转存时少走一些弯路。如果你在国产化服务器或泛微e9集成时遇到了我这里没覆盖到的问题欢迎交流讨论。
返回列表