老炮踩坑录 · D05 · 技术深挖系列
基于「企业融合评估平台」真实源码,拆解项目里两套并存的 HTM 转 PDF实现
关键词:iText XMLWorker · wkhtmltopdf · openhtmltopdf
👋 欢迎阅读

🏠个人主页: 知守观
📘我的专栏: 老炮踩坑录
💻当前内容:HTML 转 PDF 方案实战
引子
连续第二期,预告又得先勘误。
上一期我写"项目里这三个方案的痕迹都有"。翻完代码,真实情况是:痕迹只有两处。一处是 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 里引的是这套:
xml
<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 字符串,再塞给工具类:
java
// Html2PDFController.java
@RequestMapping("/createpdf")
public void HtmlToPdf(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 来兜底:
java
// PDFUtils.java
XMLWorkerFontProvider fontProvider = new XMLWorkerFontProvider(){
@Override
public Font getFont(String fontname, String encoding, float size, int style) {
return super.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 字体:
java
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 里写得很诚实,注释直接告诉你要往服务器放字体文件:
java
/*
* 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 拼:
java
// HtmlToPdf.java
StringBuilder 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 的二进制路径长这样:
yaml
wkhtmltopdf:
pdftool:
windows: F:\Program Files\wkhtmltopdf\bin\wkhtmltopdf.exe
linux: /usr/local/bin/wkhtmltopdf
而 Runtime.exec(String) 会按空白字符切分命令字符串。F:\Program Files\... 从空格处断成两截,Windows 开发机上执行的结果是找不到 F:\Program 这个程序。
代码里留着两行注释,看得出来有人跟引号搏斗过,认栽了:
java
//cmd.append(" \"");
cmd.append(srcPath);
// cmd.append("\" ");
给命令加引号这件事在字符串拼接里极其别扭------路径、参数、引号转义互相缠绕。当年生产是 Linux,路径没空格,就这么糊弄过去了。开发机具体怎么跑通的我没法考证,大概率是有人把 exe 挪到了无空格目录,或者根本没人在 Windows 上导过 PDF。
坑二:一个 GET 接口,把内网大门敞开
HtmltoPdfController 里还有个更野的接口:
java
@RequestMapping(value = "/createpdf", method = RequestMethod.GET)
@ResponseBody
public 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,参数以数组传递,每个元素是一个整体,不存在空格分词,也不需要手拼引号:
java
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,每个都是吃内存大户,机器直接卡死。
临时文件名也埋了冲突:
java
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。
最小用法:
java
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 做一次标准化预处理:
java
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 和分页规则:
css
@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] 加 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 调过),至少把外壳补成这样:
java
private final Semaphore permits = new Semaphore(3); // 全局并发开关
public Path render(Path htmlFile) throws Exception {
if (!permits.tryAcquire(1, 30, TimeUnit.SECONDS)) {
throw new 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();
throw new SystemException("PDF 生成超时");
}
if (p.exitValue() != 0) {
throw new 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老炮踩坑录」,不错过每一篇真实案例,少踩坑。