HTML 转 PDF 方案实战:iTextPDF 与 wkhtmltopdf 踩坑复盘,附 Flying Saucer / openhtmltopdf 选型对比

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

相关推荐
2601_9620715742 分钟前
类变量和全局变量的隔离性有什么区别?
java·开发语言·jvm
程序边界44 分钟前
16个通道并行灌库是什么体验——KFS入库这点事儿(下)
后端
H.莓飛1 小时前
【Linux】命令行参数、环境变量与程序地址空间
linux·c语言·chrome·后端·centos
粥里有勺糖1 小时前
拿 Codex 协助清理磁盘,Nice!
前端·后端·github
海绵宝宝转agent1 小时前
LeetCode100 LRU缓存思路讲解
java·开发语言·缓存
专业程序开发源1 小时前
springboot高校学生社团管理系统74810-计算机课程设计、毕业设计
java·vue.js·spring boot·后端·spring·php·课程设计
励志不掉头发的内向程序员1 小时前
【LibreCAD 2D架构】从鼠标点击到屏幕像素:LibreCAD绘图架构全链路解析之整体架构与源码组织
后端
youdexiang1 小时前
AI 生成会议纪要好用吗?多款 APP 功能分析
java·人工智能·音视频
通信瓦工1 小时前
OCP 冷却环境与冷却剂分配单元(CDU)子项目
java·开发语言