夜雨聆风学习资料网

ARTICLE · 1125427

HTML 转 PDF 两种真实方案我都踩过坑:iTextPDF 和 wkhtmltopdf,最后发现第三种方案才是正解

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 管版本。

三个方案摆在一起

维度
iText5 + XML Worker
wkhtmltopdf
Flying Saucer / openhtmltopdf
形态
纯 Java 库
外部二进制进程
纯 Java 库
部署成本
零安装,加 jar
每台机器装 exe/so + 中文字体
零安装,字体打进包
渲染内核
自研简易解析器
Qt WebKit(2012 年水准的真浏览器)
自研 CSS 2.1 渲染器
CSS 能力
CSS 2.1 子集
CSS 2.1 全 + 部分 CSS3,flex 半残
CSS 2.1 + @page 分页媒体
JavaScript
无
支持但默认被项目关掉
无
中文
靠系统字体或 CID 字体
靠系统字体包
@font-face 内嵌,最省心
分页排版
手写,痛苦
命令行 + CSS,可用
@page 规则,三者最强
内存模型
进程内,可控但全量
每请求一进程,重
进程内,可控
攻击面
解析器漏洞 + 反序列化
SSRF/路径穿越/参数注入,面最大
解析器漏洞,面最小
维护状态
iText 5 早停更,5.5.13.x 是末代
仓库 2023 年 1 月归档,永久停更
Saucer 停更;openhtmltopdf 在维护

进程方案的加固姿势

如果历史包袱决定了必须留在 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
字符串拼接、路径含空格、参数来自请求
转换接口吃外部 URL
看 url/path 是否外部可控
无鉴权、无白名单、可访问内网网段
进程并发与超时
看 waitFor 有没有超时和并发闸
裸 waitFor + 每请求一进程
临时文件命名
看文件名生成规则
秒级时间戳、可预测、可撞名
中文方案
字体是内嵌还是依赖系统
Linux 没装字体就出方块
HTML 良构性
有没有 jsoup 等预处理层
用户富文本直进 XML 解析器
授权合规
iText/Flying Saucer 版本与协议
iText 7 闭源商用未买授权
组件生命周期
wkhtmltopdf/iText5 版本
已归档停更且有未修 CVE

老炮点评

这个项目在 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老炮踩坑录」,不错过每一篇真实案例,少踩坑。

相关学习资料