ARTICLE · 1125427
HTML 转 PDF 两种真实方案我都踩过坑:iTextPDF 和 wkhtmltopdf,最后发现第三种方案才是正解
👋 欢迎阅读
老炮踩坑录 · D05 · 技术深挖系列
基于「企业融合评估平台」真实源码,拆解项目里两套并存的 HTML 转 PDF 实现,再补上缺席的第三种方案
关键词:iText XMLWorker · wkhtmltopdf · Flying Saucer XHTML
引子
连续第二期,预告又得先勘误。
上一期我写"项目里这三个方案的痕迹都有"。翻完代码,真实情况是:痕迹只有两处。一处是 iText 5 + XML Worker 的纯 Java 解析,一处是 Runtime.exec 调 wkhtmltopdf 二进制。Flying Saucer(org.xhtmlrenderer)在 pom 里没有,代码里也没有一个 ITextRenderer。
所以这篇内容调整一下:
两条真实路径逐个拆,看它们各自埋了什么雷;Flying Saucer 作为缺席的第三种方案,我用复盘的方式补做对照,连同它的继任者 openhtmltopdf 一起讲。选型结论放在 2026 年的时间点给——wkhtmltopdf 如今的状态,跟 2022 年又不一样了。
需求背景交代一句:
诊断报告要出 PDF,报告页面本身是个复杂 HTML——动态二级表头、企业信息、诊断结论、页眉页脚页码。页面能在浏览器里看,客户要的是"所见即所得地下载下来"。这个需求听起来好像没有什么问题,做起来全是坑。
预告写错,其实暴露了一件事:
我当时也只是看了 pom.xml 和几个类就下了判断,没翻全代码。这恰恰说明——老项目里的技术决策,光看表面文件根本看不出来。 这期把整个 PDF 链路翻了个底朝天,才发现真实情况比预告复杂得多。
路径一:iText 5 + XML Worker,纯 Java 解析
pom 里引的是这套:
<dependency><groupId>com.itextpdf</groupId><artifactId>itextpdf</artifactId><version>5.5.13.1</version></dependency><dependency><groupId>com.itextpdf.tool</groupId><artifactId>xmlworker</artifactId><version>5.5.13.1</version></dependency><dependency><groupId>com.itextpdf</groupId><artifactId>itext-asian</artifactId><version>5.2.0</version></dependency>入口是 /pdf/createpdf,先把内部页面 forward 一遍拿到渲染后的 HTML 字符串,再塞给工具类:
// Html2PDFController.java@RequestMapping("/createpdf")publicvoidHtmlToPdf(HttpServletRequest request, HttpServletResponse response)throws ... { String html = ServletUtils.forward(request, response, "/view/coursePreviewNew.html");byte[] pdf = PDFUtils.html2pdf(html); response.setContentType("application/pdf"); OutputStream out = response.getOutputStream(); out.write(pdf);}核心转换在 PDFUtils.html2pdf 里搭了一条 XML Worker 的 pipeline,中文字体靠自定义 FontProvider 来兜底:
// PDFUtils.javaXMLWorkerFontProvider fontProvider = new XMLWorkerFontProvider(){@Overridepublic Font getFont(String fontname, String encoding, float size, int style){returnsuper.getFont(fontname == null ? "宋体" : fontname, encoding, size, style); }};worker.parseXHtml(writer, document,new ByteArrayInputStream(html.getBytes("UTF-8")),new ByteArrayInputStream(html.getBytes()), // ← 注意这个参数 Charset.forName("UTF-8"), fontProvider);中文字体还有另一条路在同一个类里,用的是 itext-asian 自带的 CID 字体:
BaseFont bfCN = BaseFont.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 = "宋体";开发机器是 Windows,C 盘自带宋体,一切正常。上了 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.javaStringBuilder cmd = new StringBuilder();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);Process proc = Runtime.getRuntime().exec(cmd.toString());HtmlToPdfInterceptor error = new HtmlToPdfInterceptor(proc.getErrorStream());HtmlToPdfInterceptor output = new HtmlToPdfInterceptor(proc.getInputStream());error.start();output.start();proc.waitFor();渲染效果这条路确实好——wkhtmltopdf 内核是 Qt WebKit,一个真浏览器引擎,CSS 支持比 XML Worker 高一个段位,页眉页脚页码全是命令行参数,不用在代码里画。代价在工程和安全上,一个比一个疼。
坑一:路径里有空格,引号被人注释掉了
配置文件里 Windows 的二进制路径长这样:
wkhtmltopdf:pdftool:windows:F:\ProgramFiles\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", method = RequestMethod.GET)@ResponseBodypublic Object create(@RequestParam(value = "url", required = false) String url, @RequestParam(value = "path", required = false) String path) {boolean convert = HtmlToPdf.convert(url, path); FileDownloadUtil.delFile(path); ...}调用方传什么 URL,服务端就拿 wkhtmltopdf 去访问什么 URL;传什么 path,PDF 就往哪个路径写。没有鉴权注解,没有地址校验。
我写本篇的时候特意去查了 wkhtmltopdf 的安全公告,结果后背发凉:CVE-2022-35583,SSRF,CVSS 9.8 分——喂给 wkhtmltopdf 的 HTML 能让它向内网任意地址发请求,包括云主机的元数据端点(119.274.169.254,拿临时凭证的那个);CVE-2020-21365,路径穿越,构造过的 HTML 能把服务器本地文件读进 PDF 输出。这两个洞上游都不会修改,原因后面再说。
而这个项目的接口,连"只喂自家 HTML"都没做——URL 是请求参数。外网点一台服务器,/htmltopdf/createpdf?url=http://119.274.169.254/latest/meta-data/&path=webapps/...,自己体会。2022 年我没有这个安全意识,代码评审也没人拦。这次复盘把它列为全项目最危险的几个接口之一,跟 之前写的 FastJSON autoType 一个级别。
path 参数同样危险:输出路径完全可控,等于任意位置写文件,写进 webapps 静态目录就是 webshell 预备动作。
坑三:参数也能注入
退一步说,就算 URL 限定成自家页面,字符串拼命令还有参数注入。srcPath 里只要出现空格和 -- 开头的片段,就会被切成新的命令参数。比如路径里塞一个 --allow / 或者把 --disable-javascript 对冲掉的参数,行为全变。
正经写法是 ProcessBuilder,参数以数组传递,每个元素是一个整体,不存在空格分词,也不需要手拼引号:
ProcessBuilder pb = new ProcessBuilder( toolPath,"--disable-javascript","--quiet","--encoding", "UTF-8","--footer-center", "[page]/[topage]", htmlFile.toAbsolutePath().toString(), pdfFile.toAbsolutePath().toString());pb.redirectErrorStream(true);Process proc = pb.start();坑四:进程无超时、无限流,全是裸奔
proc.waitFor() 没有超时参数。wkhtmltopdf 卡在哪张图加载不出来(它默认会等网络资源),Tomcat 线程就永远挂在那。更刺激的是每个请求 fork 一个进程,没有并发上限——如果来十个用户同时点导出,服务器上瞬间十个 wkhtmltopdf,每个都是吃内存大户,机器直接卡死。
临时文件名也埋了冲突:
String newDate = sdf.format(new Date()) + System.currentTimeMillis() % 10000;String htmlfileName = getPdfName(pdfname) + "-" + newDate + ".html";秒级时间戳加毫秒取模一万。同一秒、同一报告名、取模撞上(一万分之一,高并发下不稀奇),两个请求读写同一个临时文件,内容互相覆盖,用户下到别人的报告。跟 F10 的 OBS 同名覆盖殊途同归。
坑五:Linux 字体与无头环境
Linux 服务器上 wkhtmltopdf 渲染中文,依赖系统字体包(常见装法是 libsoup 之类运行库 + 文泉驿或 Noto CJK 字体)。最小化安装的 CentOS 镜像里什么都没有,导出又是方块。配套还有一堆 so 库要装,换台机器重来一遍,没有镜像化部署之前,全靠运维口口相传。
第三种方案:缺席的 Flying Saucer
讲完两个真实的,再补上缺席的一个。Flying Saucer(Maven 坐标 org.xhtmlrenderer:flying-saucer-pdf-itext5,9.1.x 系,绑的也是 iText 5)走的是第三条路线:纯 Java、严格 XHTML + CSS 2.1 渲染器,输出端接 iText。
最小用法:
ITextRenderer renderer = new ITextRenderer();// 嵌入中文字体,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.Document dirty = Jsoup.parse(html);dirty.outputSettings().syntax(Document.OutputSettings.Syntax.xml);W3CDom w3c = new W3CDom();Document xhtml = w3c.fromJsoup(dirty);renderer.setDocument(xhtml, null);不做这一步,SAXParseException 会教你做人——这个报错我在别的项目上见过很多次,内容是用户从 Word 粘进富文本编辑器的 HTML,标签大开大合。
报告类 PDF 真正值钱的能力是分页控制,Flying Saucer 支持 CSS @page 和分页规则:
@page {size: A4;margin: 20mm15mm; @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] 加 CSS thead { display: table-header-group },各有各的拧巴。
代价前面也提了:CSS 2.1 天花板,flex 别想;-fs- 前缀的属性是私有扩展;渲染器对畸形 HTML 零容忍。它的现代继任者是 openhtmltopdf(com.openhtmltopdf:openhtmltopdf-pdfbox),API 几乎一脉相承,底层换成 PDFBox,Maven Central 上最新是 1.0.10(用时再核对一下仓库),SVG、RTL、日志体系都更现代。依赖收敛要小心,PDFBox 版本跟它的传递依赖对不齐时容易 NoSuchMethodError,让 BOM 管版本。
三个方案摆在一起
进程方案的加固姿势
如果历史包袱决定了必须留在 wkhtmltopdf(项目当年就是这种情况,模板全按 WebKit 调过),至少把外壳补成这样:
privatefinal Semaphore permits = new Semaphore(3); // 全局并发开关public Path render(Path htmlFile)throws Exception {if (!permits.tryAcquire(1, 30, TimeUnit.SECONDS)) {thrownew SystemException("PDF 生成排队超时,请稍后重试"); } Path pdf = Paths.get(pdfDir, UUID.randomUUID() + ".pdf");try { ProcessBuilder pb = new ProcessBuilder( toolPath, "--disable-javascript", "--quiet","--no-images", // 按需:禁止外链图片,掐断 SSRF 一大半"--load-error-handling", "ignore","--load-media-error-handling", "ignore", htmlFile.toString(), pdf.toString() ); pb.redirectErrorStream(true); Process p = pb.start(); String log;try (BufferedReader r = new BufferedReader(new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) { log = r.lines().collect(Collectors.joining("\n")); }boolean done = p.waitFor(60, TimeUnit.SECONDS);if (!done) { p.destroyForcibly();thrownew SystemException("PDF 生成超时"); }if (p.exitValue() != 0) {thrownew SystemException("PDF 生成失败: " + log); }return pdf; } 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、无外部进程、模板受控的报告场景,今天我会直接选 openhtmltopdf;HTML 是现代页面、依赖 JS 或 flex/grid,正解是 headless Chromium 系——Playwright 直接驱动,或 Gotenberg 这种把 Chrome 包成 Docker 服务的方案,渲染跟你浏览器里看到的完全一致,代价是每个转换一两百 MB 内存和 1.5GB 级别的镜像,用队列削峰。这个项目在当年具有"政府报告、模板固定、无 JS、要精确分页"的特点,openhtmltopdf 正好命中。
自查清单
Runtime.getRuntime().exec | ||
老炮点评
这个项目在 PDF 上的演进路径特别典型:先用 iText 顶着,发现 CSS 太弱转不动,换 wkhtmltopdf,效果好了就把工程和安全债全欠着,然后功能上线、人员离开、债务挂起——直到今天翻代码,才发现那个 GET 接口几乎等于把内网探活工具挂在公网上。
三类方案的差别表面上是渲染质量,骨子里是"你愿意把信任放在哪一层"。放给外部进程,就得接管进程的生命周期、权限和网络;放给纯 Java 库,就得接受它的 CSS 天花板和 XHTML 洁癖;什么都想要,就上 headless Chromium,然后接管一个浏览器集群的运维成本。
还有个老毛病在这条线上复发:写代码的人把"在我机器上能跑"当成了方案成立的证据。Windows 字体、空格路径、Linux 字体包,每一个都是环境差异,每一个都在上线那晚还回来。跨环境的东西,凡是依赖机器现状(字体、二进制、so 库)的,都要问自己一句:换一台干净的最小化镜像,它还能活吗。
下期预告:《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老炮踩坑录」,不错过每一篇真实案例,少踩坑。