
简介面向有一定Java基础的中级开发人员解决网页内容转PDF、批量生成报表等场景下的格式转换需求是一套基于ITextRenderer/Flying Saucer的HTML转PDF封装工具类。包内共23个文件以10个jar依赖为主体涵盖flying-saucer渲染器、itext生成内核、BouncyCastle加密库等核心组件同时提供Java源码、pom.xml工程配置、HTML样例、XML与TTF/TTC字体文件方便读者直接构建和运行压缩包整体约28.74MB。该资源已有3245人学习下载并附有示例HTML与生成出的PDF效果文件可对照校验输出版式。源码演示了setDocument加载HTML、layout布局计算、createPDF输出文件的标准流程同时涉及HTML中CSS样式的重要性以及外部图片字体资源的处理思路读者可基于此继续扩展URL加载、自定义标签解析、统一异常处理等能力。整体结构简洁适合作为公共转换组件嵌入业务代码也可作为学习Flying Saucer渲染原理的参考实现。 项目做到一定阶段免不了要碰“把页面内容存成 PDF”这种需求电子发票、合同存档、报表导出、对账单下载前端页面辣么好看后端要落一份 PDF 出来。我最早是拿 Apache PDFBox 一行行画坐标画到怀疑人生后来换成了 ITextRenderer——也就是 Flying Saucer 项目里的核心渲染类把一段写好的 HTML 模板直接“打印”成 PDF整个过程纯 JVM 内完成不依赖操作系统装浏览器也不用到前端去调打印插件。这篇文章就把我封装的工具类、版本坑、字体坑、CSS 兼容问题全部摊开讲一遍。这里先澄清一下标题里的“html 模块”它指的不是什么可热插拔的模块化组件而是业务上预先写好的 HTML 模板文件或模板字符串比如一段带table的发票样式、带标题和签章的合同模板。后端拿数据渲染这段 HTML再交给 ITextRenderer 转成 PDF 文件流返回。理解了这一点后面所有代码都围绕“HTML 字符串进PDF 字节流出”展开。1. 为什么选 ITextRenderer技术选型与这套方案的适用边界Java 世界里做 PDF 的路子不少但每条路的天花板不一样。我接触过的方案大致分四类PDFBoxApache 出的底层 PDF 操作库适合做 PDF 解析、表单填充、页数统计这种“直接操作 PDF 对象”的活。你要是拿它画一个表格得自己算坐标、画线、填文字工作量大到让人想转行。iText / OpenPDF提供了 Document、PdfPTable 这类相对上层的 API写代码生成表格型 PDF 比 PDFBox 顺手很多但本质还是“用代码描述 PDF 内容”样式调整要改代码重新编译运营提一个“标题字号大一点”的需求就能改半天。wkhtmltopdf / Chromium headless本质是启动外部进程用真实浏览器内核渲染网页再打印成 PDF。CSS3、JS 全都支持效果和浏览器里点“打印”几乎一样。代价是服务器要装二进制、要处理进程管理和内存回收并发一高经常出现僵尸进程把机器拖垮。Flying SaucerITextRenderer这是一个纯 Java 的 XHTML/CSS 渲染器它读入 HTML 字符串和样式表把排版结果交给底层 PDF 库输出成 PDF。因为是纯 Java 实现不启浏览器、不出进程非常适合服务端批量生成、模板驱动的报表类需求。我做的项目场景很典型数据表里查出一批订单套一个固定 HTML 模板渲染成合同或发票几十份、几百份批量导出。这种场景我对 PDF 的要求很明确——模板和样式归前端/运营调后端只负责把它变成文件。ITextRenderer 吃 HTML 字符串这个特性让它成了最适合“模板化渲染”的 Java 方案。不过它的适用边界也要先说清楚Flying Saucer 对 CSS 的支持基本停留在 CSS 2.1 的子集flex、grid这种现代布局想都不要想JS 更是完全不执行。你要是拿一个用 Vue 写的动态页面去转出来的 PDF 大概率不是你想要的样子。它适合的是“预渲染好的、结构稳定的 HTML 模板”这也是我标题里强调“html 模块”的原因——把样式可控的模板交给它它才靠得住。2. Maven 依赖与版本适配先避开最容易踩的类冲突工具类代码本身不长但依赖这块如果版本搞错一启动就会报各种NoClassDefFoundError。我现在的项目用的是这套坐标dependency groupIdorg.xhtmlrenderer/groupId artifactIdflying-saucer-pdf/artifactId version9.1.22/version /dependency它会传递引入两个关键依赖org.xhtmlrenderer:flying-saucer-core渲染核心和com.github.librepdf:openpdf负责最终 PDF 输出。有一点容易被忽略的是OpenPDF 虽然换了组织名但包名还是com.lowagie.text。所以代码里ITextRenderer对应的底层类是com.lowagie.text.pdf.BaseFont不是com.itextpdf.text.pdf.BaseFont别 import 错了。版本演进这里藏着很多老项目踩过的坑。Flying Saucer 早年直接基于 iText 2.1.7包名就是com.lowagie.text。后来 iText 5 开始走 AGPL 协议Flying Saucer 项目没法继续跟着升级干脆迁移到了开源的 OpenPDF。OpenPDF 延续了 iText 2.x 的 API 和包名所以旧代码基本不用改就能跑。但问题是很多项目里其实还引了 iText 5/7 在做别的 PDF 操作比如填表单、加水印它的包名是com.itextpdf.text。于是项目里就出现了两套 PDF 底层类一套com.lowagie.text.*一套com.itextpdf.text.*。单独看都不报错一旦某个库内部方法签名碰了或者反射加载了另一个包的同名类运行时就是各种方法找不到。我的经验是一个项目里老老实实用一套 PDF 底层库要么只用 Flying Saucer 自带的 OpenPDF要么换别的 HTML 转 PDF 方案。如果因为历史包袱必须用 iText 5那可以单独引入flying-saucer-pdf-itext5这个变体模块它专门对接 iText 5 的 API但这种情况要慎重模块内部实现和使用方式都有差异。另外一个常见问题openpdf的底层字体机制非常依赖系统字体文件。部署到精简的 Docker 容器时经常连/usr/share/fonts目录都不存在后面第 4 章会专门讲字体处理这里先有个印象依赖装完只是第一步确认部署环境有中文字体文件是这个方案能不能用的前提。3. 工具类完整实现从 HTML 字符串到 PDF 字节流的三个关键步骤我封装好的工具类长这样直接复制就能用package com.example.common.pdf; import com.lowagie.text.pdf.BaseFont; import org.xhtmlrenderer.pdf.ITextFontResolver; import org.xhtmlrenderer.pdf.ITextRenderer; import java.io.ByteArrayOutputStream; import java.io.OutputStream; import java.nio.charset.StandardCharsets; public class HtmlToPdfUtils { private static final String TEMPLATE_BASE_URL file:///data/pdf-templates/; private HtmlToPdfUtils() { } /** * HTML 片段转 PDF 字节数组 * * param htmlFragment 不包含完整 html 结构的片段如 h1标题/h1table... */ public static byte[] fragmentToPdfBytes(String htmlFragment) throws Exception { try (ByteArrayOutputStream baos new ByteArrayOutputStream()) { renderFragment(htmlFragment, baos); return baos.toByteArray(); } } /** * HTML 片段转 PDF直接写入输出流。注意方法不会关闭输出流由调用方决定。 */ public static void renderFragment(String htmlFragment, OutputStream outputStream) throws Exception { String fullHtml wrapToFullHtml(htmlFragment); render(fullHtml, outputStream); } /** * 完整 HTML 页面转 PDF直接写入输出流。 */ public static void render(String fullHtml, OutputStream outputStream) throws Exception { ITextRenderer renderer new ITextRenderer(); registerChineseFonts(renderer.getFontResolver()); renderer.setDocumentFromString(fullHtml, TEMPLATE_BASE_URL); renderer.layout(); renderer.createPDF(outputStream); } /** * 给片段套一个完整的 HTML 壳子顺便灌入全局样式。 */ private static String wrapToFullHtml(String htmlFragment) { return !DOCTYPE html\n html lang\zh-cn\\n head\n meta charset\UTF-8\\n style\n body { font-family: SimSun; font-size: 14px; line-height: 1.6; color: #333; }\n table { width: 100%; border-collapse: collapse; }\n th, td { border: 1px solid #999; padding: 6px 8px; }\n /style\n /head\n body\n htmlFragment \n/body\n/html; } /** * 注册中文字体。按操作系统区分路径生产环境建议把字体放到 resources 下分发。 */ private static void registerChineseFonts(ITextFontResolver fontResolver) throws Exception { if (isWindows()) { fontResolver.addFont(C:/Windows/Fonts/simsun.ttc, BaseFont.IDENTITY_H, BaseFont.NOT_EMBEDDED); fontResolver.addFont(C:/Windows/Fonts/msyh.ttf, BaseFont.IDENTITY_H, BaseFont.NOT_EMBEDDED); } else { fontResolver.addFont(/usr/share/fonts/chinese/simsun.ttc, BaseFont.IDENTITY_H, BaseFont.NOT_EMBEDDED); fontResolver.addFont(/usr/share/fonts/chinese/msyh.ttf, BaseFont.IDENTITY_H, BaseFont.NOT_EMBEDDED); } } private static boolean isWindows() { return System.getProperty(os.name, ).toLowerCase().contains(windows); } }这个类看起来简单但每一步都有讲究。setDocumentFromString(fullHtml, TEMPLATE_BASE_URL)是起点第一个参数是完整 HTML第二个参数是baseUrl它决定了 HTML 里相对路径的资源图片、外部 CSS往哪个目录解析。很多教程这里直接传null一旦 HTML 里有img srcimages/logo.png图片就找不到了。我习惯在固定目录放模板资源和图片baseUrl 就指向那个目录文档解析时资源定位就顺理成章。renderer.layout()是核心的排版过程Flying Saucer 会把 DOM 树和 CSS 规则结合计算出每个元素在 A4 页面上的位置和分页结果。这一步其实最吃 CPU大批量导出时要重点盯它的耗时。createPDF(outputStream)则是把排版好的页面一张张画进 PDF 里底层调用 OpenPDF 写入文件。两个细节值得注意。第一工具类方法不要主动close(outputStream)因为你不知道调用方传进来的是FileOutputStream还是response.getOutputStream()关掉了别人就没法用了。第二fragmentToPdfBytes这种要返回字节数组的场景用ByteArrayOutputStream包一层很自然但数据量大时内存会吃紧后面第 6 章讲批量导出时我再细说。4. 中文字体与 CSS 排版让 PDF 不出现方框和错位的完整方案用这个工具类遇到的头号问题绝对是中文全部变成方框。原因要追溯到 PDF 和字体的关系PDF 文档本身不含字体渲染功能它需要把字体文件嵌入或者引用系统字体。iText/OpenPDF 内置了 14 种标准字体比如 Helvetica、Times-Roman全部不支持中文。你不注册中文字体渲染到“你好”这两个字时找不到字形就画几个方框给你看。所以注册字体的代码不是可选操作而是必选操作。registerChineseFonts里用到的BaseFont.IDENTITY_H和BaseFont.NOT_EMBEDDED这两个参数说一下IDENTITY_H表示使用 Unicode 编码的横向书写模式是中文 PDF 的标准姿势NOT_EMBEDDED表示字体不嵌入 PDF 文件这样生成的文件体积小但换一台机器打开时依赖对方系统里有同样字体。如果你要保证任何设备打开都一致把NOT_EMBEDDED改成BaseFont.EMBEDDED代价是单个 PDF 体积多出几 MB。字体路径这个问题我踩过不少次。开发时在 Windows 上用C:/Windows/Fonts/simsun.ttc没问题部署到 Linux 服务器就找不到路径。后来我干脆把字体文件打进项目的resources/fonts目录从 classpath 读取绝对路径注册fontResolver.addFont( new ClassPathResource(fonts/simsun.ttc).getFile().getAbsolutePath(), BaseFont.IDENTITY_H, BaseFont.NOT_EMBEDDED );这样字体和程序一起分发部署环境再精简也不怕缺字体。如果你用的是ttc集合字体文件注意它里面可能包含多个字体族addFont会注册里面所有族名CSS 里写SimSun或宋体都能命中。字体注册完之后还有 CSS 兼容这个大头。Flying Saucer 不是 Chrome它对 CSS 的支持停留在 CSS 2.1 时代以下是我实测下来的结论flex、grid一律不支持。写display: flex它会当成普通块级元素处理布局全乱。做 PDF 模板时老老实实用tablefloat这是最稳的组合。thead会在每一页顶部自动重复。这一点非常有用做多页表格时表头不用手动处理每页都有原生打印也是这个规则。page指令部分支持。page { margin: 20mm 15mm; }实测有效但size: A4这种自定义纸张在部分版本里形同虚设。最稳的做法是接受默认 A4或者把页面边距直接写在body的margin上。page-break-before: always和page-break-inside: avoid是有效的。合同模板每章开头强制新起一页就在对应元素上加page-break-before: always表格行避免被拦腰截断给tr或者td设page-break-inside: avoid。慎用伪元素和高级选择器。:nth-child、::before这类选择器支持不完整模板里尽量用基础类和内联样式。这套约束听起来局限但在报表场景里完全够用。我的经验是前端同事写模板时先约定“不要用 flex一律 table”后面就很少为排版打架。5. 生产环境高频踩坑三个经典问题的完整排查链路5.1 类冲突NoClassDefFoundError 的完整排查一个真实案例Spring Boot 项目里同时有com.itextpdf:itextpdf:5.5.13和org.xhtmlrenderer:flying-saucer-pdf:9.0.7启动不报错一调用生成 PDF 就抛java.lang.NoClassDefFoundError: com/lowagie/text/DocumentException。排查链路是这样走的先在 IDE 里看着报错栈发现是ITextRenderer内部某个类加载com.lowagie.text.DocumentException失败接着用mvn dependency:tree看依赖确认有两个 jar 都存在com.lowagie相关类再用jar tf查看itextpdf-5.5.13.jar发现它压根没有com/lowagie目录也就是说报错是纯粹的依赖缺失或者版本不兼容不是类重复。进一步查才定位到项目中另一个工具模块强行排除了flying-saucer-pdf的传递依赖导致 OpenPDF 没被带进来。解决办法是把排除的依赖恢复或者升级到 9.1.22 重新梳理依赖树。这段经历给我留了一个教训NoClassDefFoundError先别急着怀疑代码先去mvn dependency:tree看依赖树大部分 PDF 相关的错误都是依赖被排掉或者版本冲突。5.2 中文方块两种形态两种原因中文方块有两种表现排查路径完全不同。第一种是全部中文都变方块一个中文字都没有那是字体根本没注册成功。先检查addFont的路径在目标机器上是否存在再检查有没有把BaseFont.IDENTITY_H写成BaseFont.CP1252——粗心大意就会出这种低级问题。第二种表现更隐蔽某些字是好的某些字变方块。比如“张”能显示“鑫”变成方块这种是字体文件里缺少对应字形。宋体、微软雅黑这种常见字体覆盖的汉字范围基本够用但如果模板里出现了生僻字、特殊符号字体文件里没有字形就会局部方块。解决方式是换覆盖范围更大的字体比如思源黑体或者提前对模板文本做一次字符范围检测。5.3 图片不显示或者导出卡死图片问题八成出在 baseUrl 上。HTML 里写了img srcimages/logo.pngbaseUrl 传file:///data/pdf-templates/那 Flying Saucer 会把图片解析成file:///data/pdf-templates/images/logo.png文件不存在就直接画个空白。排查时先看图片路径拼出来是什么确认文件是否真实存在。还有一种更坑的情况模板里用的是http://开头的网络图片。createPDF执行到img标签时会同步去下载图片内网不通、外网超时整个 PDF 生成就会卡在那里甚至直接抛异常超时。生产环境我后来统一做了预处理业务方先把图片上传到本地文件服务器模板里的图片地址改成相对路径指向本地目录或者干脆在拼 HTML 时把图片转成 base64 内嵌进去img srcdata:image/png;base64,/9j/4AAQSkZJRg...base64 内嵌的好处是彻底摆脱路径和网络依赖缺点是 HTML 字符串会变大且大图片会让渲染慢很多。适合 logo、签章这种小图不适合高清产品图。6. 批量导出的性能优化与下载响应头处理工具类跑通以后下一步就是考虑生产环境怎么用好它。性能上最需要注意的一点ITextRenderer每次渲染都要完整解析一遍 HTML、构建 DOM、执行 CSS 匹配这个过程没法跨请求复用。所以不要试图用一个单例ITextRenderer去处理所有请求它内部有状态线程不安全。正确做法是每次请求 new 一个但字体注册可以做一次系统级初始化把addFont的结果缓存住减少重复 IO 开销。不过实测下来字体注册的耗时占比很小真正吃时间的是layout()阶段HTML 越复杂、表格行数越多layout 越慢。批量导出几百份 PDF 时内存是最容易炸的环节。如果每一份都先toPdfBytes把所有字节攒在内存再拼成一个列表返回几百份下来堆内存直接爆。我建议改成流式写法循环里直接调用render(orderHtml, fileOutputStream)生成一份写一份不要全部攒在内存里。像这样for (Order order : orders) { String html templateEngine.process(order); String fileName order_ order.getId() .pdf; try (FileOutputStream fos new FileOutputStream(saveDir fileName)) { HtmlToPdfUtils.render(html, fos); } }模板引擎我推荐配合 Thymeleaf 或者 Freemarker 用。业务方维护一个 HTML 模板文件后端把数据模型往里一塞拿到渲染后的 HTML 再交给HtmlToPdfUtils这样模板改动不需要重新编译代码运营和前端同事都可以自己调样式。还有 Web 接口下载 PDF 时响应头的处理这里也容易翻车GetMapping(/export/invoice) public void exportInvoice(HttpServletResponse response, RequestParam String orderId) throws Exception { String html invoiceTemplateEngine.process(orderId); byte[] pdfBytes HtmlToPdfUtils.fragmentToPdfBytes(html); String fileName URLEncoder.encode(发票_ orderId .pdf, StandardCharsets.UTF_8) .replaceAll(\\, %20); response.setContentType(application/pdf); response.setHeader(Content-Disposition, attachment; filename*UTF-8 fileName); response.getOutputStream().write(pdfBytes); response.getOutputStream().flush(); }中文文件名直接放Content-Disposition里一定会乱码所以要 URL 编码并且用 RFC 5987 的filename*UTF-8写法让现代浏览器正确解码。这里有个小坑URLEncoder.encode会把空格编码成在 URL 语义里代表空格没问题但在 HTTP header 里有的浏览器会把它当成字面加号所以我习惯再用replaceAll(\\, %20)替换一下稳妥。写到最后分享一个我自己的实际习惯凡是用了这套工具类的项目我都会把字体文件打进 resources、baseUrl 固定指向一个模板资源目录、所有 HTML 片段统一走wrapToFullHtml补全结构。这三件事定下来后面基本就能安心睡大觉了。ITextRenderer 这套方案不算新但胜在稳定可控尤其适合服务端批量化、模板化的 PDF 生成场景。你如果在项目中遇到类似的导出需求不妨按这篇文章搭一个工具类先跑通再根据业务情况慢慢调字体和样式。本文还有配套的精品资源点击获取