老炮踩坑录 · D05 · 技术深挖系列基于「企业融合评估平台」真实源码拆解项目里两套并存的 HTM 转 PDF实现关键词iText XMLWorker · wkhtmltopdf · openhtmltopdf 欢迎阅读个人主页知守观我的专栏老炮踩坑录当前内容HTML 转 PDF 方案实战引子连续第二期预告又得先勘误。上一期我写项目里这三个方案的痕迹都有。翻完代码真实情况是痕迹只有两处。一处是 iText 5 XML Worker 的纯 Java 解析一处是 Runtime.exec 调 wkhtmltopdf 二进制。Flying Saucerorg.xhtmlrenderer在 pom 里没有代码里也没有一个ITextRenderer。所以这篇内容调整一下两条真实路径逐个拆看它们各自埋了什么雷Flying Saucer 作为缺席的第三种方案我用复盘的方式补做对照连同它的继任者 openhtmltopdf 一起讲。选型结论放在 2026 年的时间点给——wkhtmltopdf 如今的状态跟 2022 年又不一样了。需求背景交代一句诊断报告要出 PDF报告页面本身是个复杂的HTML——动态二级表头、企业信息、诊断结论、页眉页脚页码。页面能在浏览器里看客户要的是所见即所得地下载下来。这个需求听起来好像没有什么问题做起来全是坑。预告写错其实暴露了一件事 我当时也只是看了 pom.xml 和几个类就下了判断没翻全代码。这恰恰说明——老项目里的技术决策光看表面文件根本看不出来。这期把整个 PDF 链路翻了个底朝天才发现真实情况比预告复杂得多。路径一iText 5 XML Worker纯 Java 解析pom 里引的是这套dependencygroupIdcom.itextpdf/groupIdartifactIditextpdf/artifactIdversion5.5.13.1/version/dependencydependencygroupIdcom.itextpdf.tool/groupIdartifactIdxmlworker/artifactIdversion5.5.13.1/version/dependencydependencygroupIdcom.itextpdf/groupIdartifactIditext-asian/artifactIdversion5.2.0/version/dependency入口是/pdf/createpdf先把内部页面 forward 一遍拿到渲染后的 HTML 字符串再塞给工具类// Html2PDFController.javaRequestMapping(/createpdf)publicvoidHtmlToPdf(HttpServletRequestrequest,HttpServletResponseresponse)throws...{StringhtmlServletUtils.forward(request,response,/view/coursePreviewNew.html);byte[]pdfPDFUtils.html2pdf(html);response.setContentType(application/pdf);OutputStreamoutresponse.getOutputStream();out.write(pdf);}核心转换在PDFUtils.html2pdf里搭了一条 XML Worker 的 pipeline中文字体靠自定义 FontProvider 来兜底// PDFUtils.javaXMLWorkerFontProviderfontProvidernewXMLWorkerFontProvider(){OverridepublicFontgetFont(Stringfontname,Stringencoding,floatsize,intstyle){returnsuper.getFont(fontnamenull?宋体:fontname,encoding,size,style);}};worker.parseXHtml(writer,document,newByteArrayInputStream(html.getBytes(UTF-8)),newByteArrayInputStream(html.getBytes()),// ← 注意这个参数Charset.forName(UTF-8),fontProvider);中文字体还有另一条路在同一个类里用的是 itext-asian 自带的 CID 字体BaseFontbfCNBaseFont.createFont(STSongStd-Light,UniGB-UCS2-H,false);这套东西能跑但每个零件都带着将就的痕迹。坑一HTML 被当成 CSS 传进去了parseXHtml的第四个参数是 CSS 输入流。代码传进去的是new ByteArrayInputStream(html.getBytes())——HTML 原文又喂了一遍当样式表。当年没炸只是因为 XML Worker 解析 CSS 失败时基本静默跳过样式没生效而已。没人发现也没人知道页面上多少 CSS 一直就没起作用所有视觉效果全靠 HTML 属性和默认样式撑着。这种 bug 的可怕之处前面翻车现场系列文章都讲过如果你感兴趣的话可以翻看之前的文章。坑二CSS 支持停留在上个年代XML Worker 的 CSS 解析是 CSS 2.1 的一个子集加少量 3 的边角。div 布局、table、简单字体颜色没问题flex 不认识float 支持半残position: absolute基本随缘。报告页面一旦让前端按现代网页的方式写导出来就是散架的。实际开发中能观察到一个滑稽现象前端在浏览器里调得漂漂亮亮一导出全错位然后被迫把模板改成 table 布局——2022 年了还在用 2005 年的排版方式写页面就的为了迁就 PDF 引擎。坑三中文字体绑定运行环境AsianFontProvider里写得很诚实注释直接告诉你要往服务器放字体文件/* * Linux: /usr/share/fonts/simsun.ttc * Windows: c:/windows/fonts/simsun.ttc */fntname宋体;开发机器是 WindowsC 盘自带宋体一切正常。上了 Linux 服务器字体没装导出的 PDF 中文全变空白或方块。当年排查这个问题的时间要远比写转换代码的时间长很多。STSongStd-Light那条路能绕开服务器字体字体度量在 itext-asian 包里但它只提供字形映射显示效果就一种宋体加粗、斜体、自定义字体一概别想。坑四输入必须标准结构XML Worker 是按 XML 解析器读 HTML 的。HTML 里标签没闭合、属性没加引号、br 写成br而非br/解析器直接抛异常。浏览器能容忍的野生 HTML它一律不认。项目里解法是先用 jsoup 兜一层Jsoup.parse(html)再输出jsoup 会把不规范的 HTML 补成良构结构。但 jsoup 默认输出的还是 HTML 序列化真碰到严格场景还得开 W3CDom 转 XHTML。这一层预处理在任何 “HTML 库进 PDF” 的方案里都省不掉记住它。另外整个 PDF 是ByteArrayOutputStream全在内存攒着报告几百页时堆压力不小。路径二Runtime.exec 调 wkhtmltopdf第二处是诊断报告导出真正在用的路径把 HTML 写成临时文件调 wkhtmltopdf 命令行转成 PDF再把文件流回浏览器。转换命令在HtmlToPdf.convert里用 StringBuilder 拼// HtmlToPdf.javaStringBuildercmdnewStringBuilder();cmd.append(toPdfTool);cmd.append( );cmd.append( --header-line );cmd.append( --disable-javascript );cmd.append( --footer-center [page]/[topage] );cmd.append( --margin-top 20mm );cmd.append( --page-width 30cm );...cmd.append(srcPath);cmd.append( );cmd.append(destPath);ProcessprocRuntime.getRuntime().exec(cmd.toString());HtmlToPdfInterceptorerrornewHtmlToPdfInterceptor(proc.getErrorStream());HtmlToPdfInterceptoroutputnewHtmlToPdfInterceptor(proc.getInputStream());error.start();output.start();proc.waitFor();渲染效果这条路确实好——wkhtmltopdf 内核是 Qt WebKit一个真浏览器引擎CSS 支持比 XML Worker 高一个段位页眉页脚页码全是命令行参数不用在代码里画。代价在工程和安全上一个比一个疼。坑一路径里有空格引号被人注释掉了配置文件里 Windows 的二进制路径长这样wkhtmltopdf:pdftool:windows:F:\Program Files\wkhtmltopdf\bin\wkhtmltopdf.exelinux:/usr/local/bin/wkhtmltopdf而Runtime.exec(String)会按空白字符切分命令字符串。F:\Program Files\...从空格处断成两截Windows 开发机上执行的结果是找不到F:\Program这个程序。代码里留着两行注释看得出来有人跟引号搏斗过认栽了//cmd.append( \);cmd.append(srcPath);// cmd.append(\ );给命令加引号这件事在字符串拼接里极其别扭——路径、参数、引号转义互相缠绕。当年生产是 Linux路径没空格就这么糊弄过去了。开发机具体怎么跑通的我没法考证大概率是有人把 exe 挪到了无空格目录或者根本没人在 Windows 上导过 PDF。坑二一个 GET 接口把内网大门敞开HtmltoPdfController里还有个更野的接口RequestMapping(value/createpdf,methodRequestMethod.GET)ResponseBodypublicObjectcreate(RequestParam(valueurl,requiredfalse)Stringurl,RequestParam(valuepath,requiredfalse)Stringpath){booleanconvertHtmlToPdf.convert(url,path);FileDownloadUtil.delFile(path);...}调用方传什么 URL服务端就拿 wkhtmltopdf 去访问什么 URL传什么 pathPDF 就往哪个路径写。没有鉴权注解没有地址校验。我写本篇的时候特意去查了 wkhtmltopdf 的安全公告结果后背发凉CVE-2022-35583SSRFCVSS 9.8 分——喂给 wkhtmltopdf 的 HTML 能让它向内网任意地址发请求包括云主机的元数据端点119.274.169.254拿临时凭证的那个CVE-2020-21365路径穿越构造过的 HTML 能把服务器本地文件读进 PDF 输出。这两个洞上游都不会修改原因后面再说。而这个项目的接口连只喂自家 HTML都没做——URL 是请求参数。外网点一台服务器/htmltopdf/createpdf?urlhttp://119.274.169.254/latest/meta-data/pathwebapps/...自己体会。2022 年我没有这个安全意识代码评审也没人拦。这次复盘把它列为全项目最危险的几个接口之一跟 之前写的 FastJSON autoType 一个级别。path参数同样危险输出路径完全可控等于任意位置写文件写进 webapps 静态目录就是 webshell 预备动作。坑三参数也能注入退一步说就算 URL 限定成自家页面字符串拼命令还有参数注入。srcPath 里只要出现空格和--开头的片段就会被切成新的命令参数。比如路径里塞一个--allow /或者把--disable-javascript对冲掉的参数行为全变。正经写法是ProcessBuilder参数以数组传递每个元素是一个整体不存在空格分词也不需要手拼引号ProcessBuilderpbnewProcessBuilder(toolPath,--disable-javascript,--quiet,--encoding,UTF-8,--footer-center,[page]/[topage],htmlFile.toAbsolutePath().toString(),pdfFile.toAbsolutePath().toString());pb.redirectErrorStream(true);Processprocpb.start();坑四进程无超时、无限流全是裸奔proc.waitFor()没有超时参数。wkhtmltopdf 卡在哪张图加载不出来它默认会等网络资源Tomcat 线程就永远挂在那。更刺激的是每个请求 fork 一个进程没有并发上限——如果来十个用户同时点导出服务器上瞬间十个 wkhtmltopdf每个都是吃内存大户机器直接卡死。临时文件名也埋了冲突StringnewDatesdf.format(newDate())System.currentTimeMillis()%10000;StringhtmlfileNamegetPdfName(pdfname)-newDate.html;秒级时间戳加毫秒取模一万。同一秒、同一报告名、取模撞上一万分之一高并发下不稀奇两个请求读写同一个临时文件内容互相覆盖用户下到别人的报告。跟 F10 的 OBS 同名覆盖殊途同归。坑五Linux 字体与无头环境Linux 服务器上 wkhtmltopdf 渲染中文依赖系统字体包常见装法是 libsoup 之类运行库 文泉驿或 Noto CJK 字体。最小化安装的 CentOS 镜像里什么都没有导出又是方块。配套还有一堆 so 库要装换台机器重来一遍没有镜像化部署之前全靠运维口口相传。第三种方案缺席的 Flying Saucer讲完两个真实的再补上缺席的一个。Flying SaucerMaven 坐标org.xhtmlrenderer:flying-saucer-pdf-itext59.1.x 系绑的也是 iText 5走的是第三条路线纯 Java、严格 XHTML CSS 2.1 渲染器输出端接 iText。最小用法ITextRendererrenderernewITextRenderer();// 嵌入中文字体IDENTITY_H 保证中文正常显示renderer.getFontResolver().addFont(PdfRender.class.getResourceAsStream(/fonts/SourceHanSansCN-Regular.otf),BaseFont.IDENTITY_H,BaseFont.EMBEDDED);renderer.setDocumentFromString(xhtml);renderer.layout();renderer.createPDF(outputStream);renderer.finishPDF();中文字体的正解是把字体文件打进 classpath用addFont(InputStream)加载并设置 EMBEDDED——PDF 里内嵌字体子集服务器装不装中文字体都无所谓拷到哪台机器打开都一样。iText 5 内嵌字体有个授权细节要留意字体本身的开源协议思源系列 OFL 就很干净别踩商业字体的坑。XHTML 严格性的问题拿 jsoup 做一次标准化预处理org.jsoup.nodes.DocumentdirtyJsoup.parse(html);dirty.outputSettings().syntax(Document.OutputSettings.Syntax.xml);W3CDomw3cnewW3CDom();Documentxhtmlw3c.fromJsoup(dirty);renderer.setDocument(xhtml,null);不做这一步SAXParseException会教你做人——这个报错我在别的项目上见过很多次内容是用户从 Word 粘进富文本编辑器的 HTML标签大开大合。报告类 PDF 真正值钱的能力是分页控制Flying Saucer 支持 CSS page 和分页规则page{size:A4;margin:20mm 15mm;bottom-center{content:counter(page) / counter(pages);}}table{-fs-table-paginate:paginate;}/* 跨页表格自动重复表头 */tr, .no-break{page-break-inside:avoid;}表格跨页自动重复表头、明细行不允许拦腰截断这些恰好是诊断报告的刚需。XML Worker 上做同样的事得手写文档事件wkhtmltopdf 靠--footer-center [page]/[topage]加 CSSthead { display: table-header-group }各有各的拧巴。代价前面也提了CSS 2.1 天花板flex 别想-fs-前缀的属性是私有扩展渲染器对畸形 HTML 零容忍。它的现代继任者是 openhtmltopdfcom.openhtmltopdf:openhtmltopdf-pdfboxAPI 几乎一脉相承底层换成 PDFBoxMaven Central 上最新是 1.0.10用时再核对一下仓库SVG、RTL、日志体系都更现代。依赖收敛要小心PDFBox 版本跟它的传递依赖对不齐时容易 NoSuchMethodError让 BOM 管版本。三个方案摆在一起维度iText5 XML WorkerwkhtmltopdfFlying Saucer / openhtmltopdf形态纯 Java 库外部二进制进程纯 Java 库部署成本零安装加 jar每台机器装 exe/so 中文字体零安装字体打进包渲染内核自研简易解析器Qt WebKit2012 年水准的真浏览器自研 CSS 2.1 渲染器CSS 能力CSS 2.1 子集CSS 2.1 全 部分 CSS3flex 半残CSS 2.1 page 分页媒体JavaScript无支持但默认被项目关掉无中文靠系统字体或 CID 字体靠系统字体包font-face 内嵌最省心分页排版手写痛苦命令行 CSS可用page 规则三者最强内存模型进程内可控但全量每请求一进程重进程内可控攻击面解析器漏洞 反序列化SSRF/路径穿越/参数注入面最大解析器漏洞面最小维护状态iText 5 早停更5.5.13.x 是末代仓库 2023 年 1 月归档永久停更Saucer 停更openhtmltopdf 在维护进程方案的加固姿势如果历史包袱决定了必须留在 wkhtmltopdf项目当年就是这种情况模板全按 WebKit 调过至少把外壳补成这样privatefinalSemaphorepermitsnewSemaphore(3);// 全局并发开关publicPathrender(PathhtmlFile)throwsException{if(!permits.tryAcquire(1,30,TimeUnit.SECONDS)){thrownewSystemException(PDF 生成排队超时请稍后重试);}PathpdfPaths.get(pdfDir,UUID.randomUUID().pdf);try{ProcessBuilderpbnewProcessBuilder(toolPath,--disable-javascript,--quiet,--no-images,// 按需禁止外链图片掐断 SSRF 一大半--load-error-handling,ignore,--load-media-error-handling,ignore,htmlFile.toString(),pdf.toString());pb.redirectErrorStream(true);Processppb.start();Stringlog;try(BufferedReaderrnewBufferedReader(newInputStreamReader(p.getInputStream(),StandardCharsets.UTF_8))){logr.lines().collect(Collectors.joining(\n));}booleandonep.waitFor(60,TimeUnit.SECONDS);if(!done){p.destroyForcibly();thrownewSystemException(PDF 生成超时);}if(p.exitValue()!0){thrownewSystemException(PDF 生成失败: log);}returnpdf;}finally{permits.release();}}配套四件事HTML 只渲染服务端自己生成的临时文件禁止外部 URL 入参非要支持 URL建域名白名单且禁止内网网段输出目录固定文件名 UUID调用方碰不到真实路径wkhtmltopdf 跑在低权限账号或独立容器里网络出向默认拒绝临时文件 try-with-resources 兜底清理别像原代码那样失败路径漏删站在 2026 年回头看写这篇时我顺手查了这些工具的近况发现变化可不小。wkhtmltopdf 仓库 2023 年 1 月 2 日归档只读组织 2024 年 7 月归档末代版本停在 2020 年 6 月的 0.12.6前面提到的两个 CVE 永远不会有官方补丁。它现在的合理定位只剩一种HTML 完全自产、主机网络隔离、迁移排不上期的存量系统当作带安全倒计时的技术债养着。新项目还在技术选型文章里抄 wkhtmltopdf 命令的该更新知识库了。iText 5 同样停在 5.5.13.x。iText 7 的 pdfHTML 模块渲染能力强了一大截但 iText 7 是 AGPL/商业双授权商用闭源要买 license这个授权变化很多团队不知道上线了才被法务找。纯 Java、无外部进程、模板受控的报告场景今天我会直接选 openhtmltopdfHTML 是现代页面、依赖 JS 或 flex/grid正解是 headless Chromium 系——Playwright 直接驱动或 Gotenberg 这种把 Chrome 包成 Docker 服务的方案渲染跟你浏览器里看到的完全一致代价是每个转换一两百 MB 内存和 1.5GB 级别的镜像用队列削峰。这个项目在当年具有政府报告、模板固定、无 JS、要精确分页的特点openhtmltopdf 正好命中。有个细节我没完全确认openhtmltopdf 对复杂中文报表的渲染效果和 wkhtmltopdf 到底差多少我本地没做过完整对比有踩过坑的兄弟评论区指点一下。自查清单检查项怎么查危险信号命令拼接执行搜Runtime.getRuntime().exec字符串拼接、路径含空格、参数来自请求转换接口吃外部 URL看 url/path 是否外部可控无鉴权、无白名单、可访问内网网段进程并发与超时看 waitFor 有没有超时和并发闸裸 waitFor 每请求一进程临时文件命名看文件名生成规则秒级时间戳、可预测、可撞名中文方案字体是内嵌还是依赖系统Linux 没装字体就出方块HTML 良构性有没有 jsoup 等预处理层用户富文本直进 XML 解析器授权合规iText/Flying Saucer 版本与协议iText 7 闭源商用未买授权组件生命周期wkhtmltopdf/iText5 版本已归档停更且有未修 CVE自查打卡搜一下你项目里有没有 Runtime.exec 或 ProcessBuilder 调用外部命令打在评论区我看看谁的项目最“干净”。老炮点评这个项目在 PDF 上的演进路径特别典型先用 iText 顶着发现 CSS 太弱转不动换 wkhtmltopdf效果好了就把工程和安全债全欠着然后功能上线、人员离开、债务挂起——直到今天翻代码才发现那个 GET 接口几乎等于把内网探活工具挂在公网上。三类方案的差别表面上是渲染质量骨子里是你愿意把信任放在哪一层。放给外部进程就得接管进程的生命周期、权限和网络放给纯 Java 库就得接受它的 CSS 天花板和 XHTML 洁癖什么都想要就上 headless Chromium然后接管一个浏览器集群的运维成本。还有个老毛病在这条线上复发写代码的人把在我机器上能跑当成了方案成立的证据。Windows 字体、空格路径、Linux 字体包每一个都是环境差异每一个都在上线那晚还回来。跨环境的东西凡是依赖机器现状字体、二进制、so 库的都要问自己一句换一台干净的最小化镜像它还能活吗。最后问一句你们项目的HTML转PDF用的哪种方案iText、wkhtmltopdf还是openhtmltopdf评论区聊聊我看看哪个用的人最多。下期预告《Spring Boot 2.1.0 → 3.x/4.0 迁移实战我踩了多少坑》上一篇我给这个项目开了张“死亡证明”——Spring Boot 2.1.0停维护 5 年10 个依赖 4 个有 CVE。但光开证明没用咱得动手术。下期我拿硬盘上那份备份代码当实验品从 2.1.0 往 3.x 爬。javax全变jakarta、FastJSON 换 Jackson、WAR 改 fat jar、JDK 8 升 17——每一步都留痕每个报错都截图。不是教程是一个老项目的真实迁移实录。如果你手里也有停维护的老系统下期这篇可以当避坑地图。如果本文对你有帮助欢迎点赞 ⭐收藏 关注 留言你的每一次小小的鼓励都是我继续更新的动力我们下一篇见我是老炮18 年 Java 老兵仍在一线。关注「Java老炮踩坑录」不错过每一篇真实案例少踩坑。