Spring Boot 里生成 PDF 的场景很常见,比如电子合同、发货单、对账单、考试报告等。方案大致分为两类:一类是以 iText、PDFBox 为代表的底层绘制,另一类是把 HTML 页面转换成 PDF。底层绘制能精确控制每个坐标,但遇到复杂表格、长文本换行、动态内容时开发和维护成本会明显上升。HTML 转 PDF 则可以直接复用前端样式经验,尤其适合需要频繁调整打印效果的场景。

一、技术选型:HTML 转 PDF 的组合思路
在 JVM 生态里,HTML 转 PDF 的常见组合是模板引擎加渲染器。模板层可以使用 Thymeleaf、Freemarker 或 Velocity,渲染层则有 Flying Saucer、OpenHTMLToPDF 等。Flying Saucer 基于 iText 2.x 实现,对 CSS 2.1 支持较完整,能够识别 @page 规则、分页属性以及部分打印媒体样式。OpenHTMLToPDF 是 Flying Saucer 的延续分支,修复了不少旧问题,对 CSS 3 的某些特性支持更好。
从实际项目角度看,Thymeleaf 与 Flying Saucer 的搭配资料较多,集成也简单。但如果项目对现代 CSS 特性依赖较强,例如需要使用 flex 或 grid 排版,建议优先评估 OpenHTMLToPDF。不过要注意,PDF 的排版模型与浏览器并不完全一致,即便使用支持度较好的渲染器,仍然建议以表格、块级元素、浮动等传统手段为主,打印样式才能更稳定。
在 Spring Boot 工程中引入依赖后,核心流程通常是:先读取模板文件,再注入动态数据生成完整 HTML 字符串,最后通过渲染器的 API 输出 PDF 字节数组。下面这段 Maven 配置展示了 OpenHTMLToPDF 的引入方式,版本号可以根据实际仓库调整。
<dependency>
<groupId>com.openhtmltopdf</groupId>
<artifactId>openhtmltopdf-pdfbox</artifactId>
<version>1.0.10</version>
</dependency>
如果团队更熟悉 Flying Saucer,也可以使用 org.xhtmlrenderer 下的依赖,但需要注意中文字体必须单独注册,否则生成出来的 PDF 中文会变成乱码或空白。
二、中文字体嵌入与基础打印样式
中文字体是 HTML 转 PDF 绕不开的问题。渲染器不会自动识别操作系统字体,必须把字体文件当作资源加载并注册到渲染上下文。常用的中文字体包括宋体、黑体、微软雅黑等,如果涉及商用发布,建议使用开源字体如思源宋体、思源黑体或阿里巴巴普惠体。字体文件可以放在 Spring Boot 的 resources/fonts 目录下,服务启动后读取为流。
注册字体时,需要把字体文件添加到渲染器的字体解析器,并在 CSS 中通过 font-family 指定对应的字体名称。这里的字体名称要与注册时传入的名称保持一致,而不是文件名的简单去后缀。下面是一段注册思路的 Java 代码,展示了如何加载 resources/fonts/simsun.ttf 并生成 PDF。
import java.io.InputStream;
import java.io.OutputStream;
import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
public byte[] generatePdf(String htmlContent) throws Exception {
try (InputStream fontStream = getClass().getResourceAsStream("/fonts/simsun.ttf")) {
PdfRendererBuilder builder = new PdfRendererBuilder();
builder.useFont(fontStream, "SimSun");
builder.withHtmlContent(htmlContent, null);
try (OutputStream os = new java.io.ByteArrayOutputStream()) {
builder.toStream(os);
builder.run();
return ((java.io.ByteArrayOutputStream) os).toByteArray();
}
}
}
CSS 的基础打印样式同样重要。页面尺寸、边距、字体大小都可以通过 @page 和常规样式控制。以下 CSS 片段定义了 A4 页面、合理的页边距以及全局中文字体,写在 HTML 模板的 <style> 标签中即可。
@page {
size: A4;
margin: 2cm 1.8cm 2.2cm 1.8cm;
}
body {
font-family: "SimSun", serif;
font-size: 12pt;
line-height: 1.6;
color: #333;
}
table {
width: 100%;
border-collapse: collapse;
}
td, th {
border: 1px solid #999;
padding: 6pt 8pt;
}
有时候生成出来的 PDF 字体偏粗或行距过密,往往是因为没有给字体指定正确的字重,或者浏览器预览与 PDF 渲染器对默认行高的计算不同。建议在 body 上显式设置 line-height,并避免使用 em 单位做精细排版,pt 和 cm 更适合打印场景。
三、分页控制与页眉页脚
打印样式的核心痛点之一就是分页。报表表格动辄几十行,如果不做控制,表格行很可能被拦腰截断,或者标题与内容分离。CSS 的 page-break-before、page-break-after 以及 break-inside 属性可以解决大部分问题。Flying Saucer 和 OpenHTMLToPDF 都支持这些规则的常用取值,尤其是 break-inside: avoid 可以阻止某个块元素在分页时被切断。
对于表格,推荐将表头放在 <thead> 中,并给 <tr>、<td> 设置 break-inside: avoid。这样每一页都会自动重复表头,单行内容也不会被切开。下面的 CSS 示例适用于合同明细、发货清单等长表格。
thead {
display: table-header-group;
}
tr {
break-inside: avoid;
page-break-inside: avoid;
}
.section {
page-break-before: always;
}
.avoid-break {
page-break-inside: avoid;
}
页眉页脚与页码可以使用 @page 的边距框规则来实现。例如 @bottom-center 可以定义页面底部中央的内容,使用 content: counter(page) 可以输出当前页码。这种方式不依赖额外 Java 代码,调整起来非常直观。不过需要注意,Flying Saucer 旧版本对 @bottom-center 的支持因版本而异,OpenHTMLToPDF 的支持更稳定一些。
@page {
@bottom-center {
content: "第 " counter(page) " 页 / 共 " counter(pages) " 页";
font-size: 9pt;
color: #666;
}
@top-right {
content: "发货单号:D20240201";
font-size: 9pt;
color: #666;
}
}
如果希望在文档第一页不显示页眉,可以给第一页单独定义 @page:first 并留空对应边距框。若是合同封面、报告首页等场景,这个技巧非常实用。
四、Spring Boot 集成与容易忽略的细节
在 Spring Boot 控制器中,生成 PDF 后一般直接通过 HttpServletResponse 输出,让浏览器以附件或内联方式打开。响应头中的 Content-Type 应设置为 application/pdf,Content-Disposition 可以控制文件名和打开方式。以下是一个完整的控制器示例,模板使用 Thymeleaf 渲染,再调用 PDF 生成服务。
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import javax.servlet.http.HttpServletResponse;
import java.io.OutputStream;
@Controller
public class PdfController {
private final TemplateEngine templateEngine;
private final PdfService pdfService;
public PdfController(TemplateEngine templateEngine, PdfService pdfService) {
this.templateEngine = templateEngine;
this.pdfService = pdfService;
}
@GetMapping("/export/invoice")
public void exportInvoice(HttpServletResponse response) throws Exception {
Context context = new Context();
context.setVariable("orderNo", "D20240201");
context.setVariable("customerName", "某科技有限公司");
String html = templateEngine.process("invoice", context);
byte[] pdfBytes = pdfService.generatePdf(html);
response.setContentType("application/pdf");
response.setHeader("Content-Disposition", "attachment; filename=invoice.pdf");
response.setContentLength(pdfBytes.length);
try (OutputStream os = response.getOutputStream()) {
os.write(pdfBytes);
os.flush();
}
}
}
图片路径是另一个容易踩坑的地方。渲染器通常无法直接识别相对路径,应该使用绝对文件路径或完整的 URL。例如模板中的 <img> 标签最好写成 <img src="file:/data/images/logo.png"/> 或者 <img src="https://your-domain/logo.png"/>。如果图片只放在 classpath 下,需要先把资源复制到临时目录,再拼接 file: 路径。
性能方面,频繁创建字体流和渲染器会带来不必要的开销。字体流可以缓存在内存中,渲染器也可以在每次调用时新创建,但字体解析器可以复用。对于高并发场景,建议将 HTML 模板编译结果缓存起来,只替换动态数据,减少模板解析时间。此外,长文档的生成耗时与页数基本成正比,必要时可以拆分成多个小 PDF 再合并。
最后要提醒的是,CSS 中的 flexbox、grid、position: absolute 等现代布局在 PDF 渲染器中支持度有限。调试打印样式时,不要用浏览器渲染结果作为最终依据,最好直接查看生成的 PDF。可以先把 HTML 模板保存成独立文件,用渲染器的调试模式检查警告信息,快速定位不支持的 CSS 规则。
Spring BootPDF生成打印样式优化修改时间:2026-09-29 17:10:12