PDF文件生成是常见需求——从报表导出、合同签署到票据打印,都需要服务端高效生成标准格式的PDF文档。但传统实现往往面临中文乱码、样式错乱、分页异常等问题,调试过程耗时费力。那么,基于这种情况,htmltopdf来了……
技术选型:
目前主流的HTML转PDF框架对比:
| 框架/方案 | 核心原理/特点 | 适用场景 | 关键优势 | 潜在考量 | 缺点 |
|---|---|---|---|---|---|
| wkhtmltopdf | 基于 Qt WebKit 引擎的命令行工具,通过进程调用。本质是无头浏览器。 | 对现代 CSS 和复杂页面布局保真度要求高的场景。 | 渲染质量高,对现代 Web 标准(HTML5, CSS3)支持良好;原生支持中文,配置极简,API直观。 | 需在服务器上单独安装该工具,部署稍复杂。 | 高级特性较少 |
| Flying Saucer + OpenPDF | Flying Saucer负责将 HTML+CSS 转换为 PDF 文档模型,OpenPDF(基于 iText 5)负责 PDF 生成。 | 需要开源免费、且对 CSS 样式支持较好的 Java 纯方案。 | 纯 Java 实现,集成方便;对 CSS 支持较好;成熟稳定,社区活跃。 | 对非常复杂或前沿的 CSS 特性支持可能不如无头浏览器方案。 | 依赖较多,转换速度慢 |
| iText | 功能强大的原生 PDF 操作库,可编程创建 PDF,也提供 HTML 转换模块。 | 需要极致性能控制、处理复杂 PDF 逻辑(如加密、签名、动态表格)的场景。 | 功能全面,API 强大,性能优秀。 | 学习曲线较陡峭;AGPL 开源协议,商业用途需购买许可证或开源代码。 | 学习成本高,需要购买 |
| OpenHTMLtoPDF | 另一个纯 Java 的 HTML 转 PDF 库,基于 Apache PDFBox。 | 需要开源协议友好(Apache License)、且支持高质量排版(如字体、分页)的项目。 | 开源免费,采用 Apache 许可证,对商业应用友好;支持复杂CSS,扩展性强。 | 社区生态和资料相对 iText 或 wkhtmltopdf 可能少一些。 | 需手动配置中文字体 |
选型结论:
htmltopdf凭借“零配置支持中文”和“5分钟集成”的优势,成为快速开发的首选。
技术实现:从依赖配置到完整接口
环境准备与依赖配置
1. Maven依赖(pom.xml)
在pom.xml中添加以下依赖,包含SpringBoot Web、Thymeleaf模板引擎和HTML转PDF核心框架:
xml
<!-- SpringBoot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Thymeleaf模板引擎(用于HTML模板渲染) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!-- HTML转PDF核心框架 -->
<dependency>
<groupId>io.woo</groupId>
<artifactId>htmltopdf</artifactId>
<version>1.0.4</version>
</dependency>
<!-- 工具类依赖(用于IO操作) -->
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.15.1</version>
</dependency>
2. 配置文件(application.yml)
无需额外配置,框架默认支持UTF-8编码和A4纸尺寸,如需自定义可添加:
yaml
# PDF转换配置(可选)
html-to-pdf:
page-size: A4 # 页面尺寸,可选A3、Letter等
margin: 10mm # 页边距,默认10mm
核心工具类封装:PDF生成工具
将PDF转换逻辑封装为工具类,便于复用和维护。工具类需实现“HTML内容转PDF流”的核心功能,并处理异常:
java
import io.woo.htmltopdf.HtmlToPdf;
import io.woo.htmltopdf.HtmlToPdfObject;
import org.apache.commons.io.IOUtils;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
/**
* PDF生成工具类
* 封装HTML转PDF的核心逻辑,提供简洁API
*/
@Component
public class PdfGeneratorUtil {
/**
* 将HTML内容转换为PDF并写入响应流
* @param htmlContent HTML字符串内容
* @param response 响应对象,用于输出PDF文件
* @param fileName 下载文件名(不含.pdf后缀)
* @throws IOException 转换或IO异常
*/
public void generatePdf(String htmlContent, HttpServletResponse response, String fileName) throws IOException {
// 设置响应头,指定为PDF文件
response.setContentType("application/pdf");
response.setHeader("Content-Disposition", "attachment; filename=" +
new String((fileName + ".pdf").getBytes("UTF-8"), "ISO-8859-1"));
// 核心转换逻辑:使用io.woo.htmltopdf生成PDF
try (InputStream pdfStream = HtmlToPdf.create()
.object(HtmlToPdfObject.forHtml(htmlContent) // 传入HTML内容
.defaultEncoding("UTF-8")) // 指定编码,避免中文乱码
.convert(); // 执行转换,返回PDF输入流
OutputStream out = response.getOutputStream()) {
// 将PDF流写入响应输出流
IOUtils.copyLarge(pdfStream, out);
out.flush();
} catch (Exception e) {
// 转换失败时抛出异常,由全局异常处理器捕获
throw new IOException("PDF生成失败:" + e.getMessage(), e);
}
}
}
HTML模板设计:最佳实践与避坑指南
使用Thymeleaf模板引擎渲染动态HTML,设计时需注意PDF转换的特殊性:
1. 模板文件位置
在src/main/resources/templates目录下创建模板文件(如report.html)。
2. 设计要点与示例
html
<!DOCTYPE html>
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>车辆评估报告</title>
<style>
/* 关键:设置PDF页面宽度适配A4纸(21cm-左右边距) */
.container { width: 19cm; margin: 0 auto; padding: 1cm; }
/* 标题样式 */
.title { text-align: center; font-size: 20px; margin-bottom: 20px; }
/* 表格样式 */
.info-table { width: 100%; border-collapse: collapse; margin: 15px 0; }
.info-table th, .info-table td {
border: 1px solid #333;
padding: 8px;
text-align: left;
}
/* 分页控制:强制在指定元素后分页 */
.page-break { page-break-after: always; }
</style>
</head>
<body>
<div class="container">
<h1 class="title" th:text="${title}">车辆评估报告</h1>
<table class="info-table">
<tr><th>品牌</th><td th:text="${car.brand}">路虎</td></tr>
<tr><th>车系</th><td th:text="${car.series}">Defender</td></tr>
<tr><th>年款</th><td th:text="${car.year}">2023款</td></tr>
<tr><th>成交价</th><td th:text="${car.price}">1000000元</td></tr>
</table>
<!-- 分页示例:下一页从这里开始 -->
<div class="page-break"></div>
<h2>第二页内容</h2>
</div>
</body>
</html>
3. 设计最佳实践
- 尺寸适配:PDF默认A4纸宽度21cm,建议内容容器宽度设为19cm(预留左右边距各1cm);
- 样式简化:避免使用复杂CSS(如flex/grid布局),优先用table和inline样式;
- 分页控制:通过page-break-after: always强制分页,避免内容跨页断裂;
- 图片处理:使用绝对路径(如https://xxx.com/img.jpg)或Base64编码,避免相对路径导致图片丢失。
Controller层实现:完整接口示例
创建Controller接收请求,渲染模板为HTML,调用工具类生成PDF:
java
import org.springframework.beans.factory.annotation.Autowired;
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.IOException;
import java.util.HashMap;
import java.util.Map;
/**
* PDF生成控制器
* 提供HTTP接口,接收参数并生成PDF文件
*/
@Controller
public class PdfController {
@Autowired
private PdfGeneratorUtil pdfGeneratorUtil; // 注入PDF工具类
@Autowired
private TemplateEngine templateEngine; // Thymeleaf模板引擎
/**
* 生成车辆评估报告PDF
* @param id 车辆ID,用于查询评估数据
* @param response 响应对象,输出PDF
* @throws IOException 异常
*/
@GetMapping("/pdf/car-report")
public void generateCarReport(Long id, HttpServletResponse response) throws IOException {
// 1. 模拟查询车辆数据(实际项目中从数据库获取)
Map<String, Object> carData = new HashMap<>();
carData.put("title", "车辆评估报告");
Map<String, String> car = new HashMap<>();
car.put("brand", "路虎");
car.put("series", "Defender 130");
car.put("year", "2023款");
car.put("price", "1000000元");
carData.put("car", car);
// 2. 渲染Thymeleaf模板为HTML字符串
Context context = new Context();
context.setVariables(carData);
String htmlContent = templateEngine.process("report", context); // "report"对应templates/report.html
// 3. 调用工具类生成PDF并输出
pdfGeneratorUtil.generatePdf(htmlContent, response, "车辆评估报告_" + id);
}
}
异常处理与性能优化
1. 全局异常处理
创建全局异常处理器,捕获转换过程中的异常并返回友好提示:
java
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(IOException.class)
public void handlePdfException(IOException e, HttpServletResponse response) throws IOException {
response.setContentType("text/plain;charset=UTF-8");
response.getWriter().write("PDF生成失败:" + e.getMessage());
response.setStatus(500);
}
}
2. 性能优化建议
- 模板缓存:Thymeleaf默认缓存模板,生产环境需开启(spring.thymeleaf.cache=true);
- 异步处理:耗时的PDF生成(如多页报表)通过@Async异步执行,避免阻塞请求;
- 资源复用:复用HtmlToPdf实例,减少对象创建开销;
- 并发控制:通过线程池限制PDF转换并发数,避免服务器资源耗尽。
场景应用:3个可直接复用的实战案例
案例1:车辆评估报告生成
业务场景:二手车交易平台,用户查看车辆评估后可下载PDF报告。关键代码:已在Controller层示例中实现,通过/pdf/car-report?id=1调用,返回车辆评估PDF。
案例2:电子合同导出
业务场景:在线签约系统,用户签署合同后导出PDF合同文件。实现步骤:
- 创建合同模板contract.html,包含甲方、乙方、条款等动态字段;
- 编写Controller接口,接收合同ID,查询签约信息;
- 渲染模板并生成PDF,关键代码如下:
java
@GetMapping("/pdf/contract")
public void generateContract(Long contractId, HttpServletResponse response) throws IOException {
// 模拟查询合同数据
Map<String, Object> contractData = new HashMap<>();
contractData.put("partyA", "张三");
contractData.put("partyB", "李四");
contractData.put("amount", "50000元");
contractData.put("signDate", "2025-10-14");
// 渲染模板并生成PDF
Context context = new Context();
context.setVariables(contractData);
String html = templateEngine.process("contract", context);
pdfGeneratorUtil.generatePdf(html, response, "电子合同_" + contractId);
}
案例3:票据打印(如发票、收据)
业务场景:电商平台订单支付后,用户下载PDF发票用于报销。实现要点:
- 票据尺寸通常为“票据专用纸”(如21cm×14cm),需自定义PDF页面大小;
- 包含二维码/条形码,便于扫码验证;
- 关键代码(自定义页面大小):
java
// 在工具类中扩展自定义页面大小的方法
public void generatePdfWithCustomSize(String htmlContent, HttpServletResponse response,
String fileName, float width, float height) throws IOException {
response.setContentType("application/pdf");
response.setHeader("Content-Disposition", "attachment; filename=" +
new String((fileName + ".pdf").getBytes("UTF-8"), "ISO-8859-1"));
try (InputStream pdfStream = HtmlToPdf.create()
.object(HtmlToPdfObject.forHtml(htmlContent).defaultEncoding("UTF-8"))
.size(width, height) // 自定义页面宽高(单位:毫米)
.convert();
OutputStream out = response.getOutputStream()) {
IOUtils.copyLarge(pdfStream, out);
out.flush();
}
}
// 调用时指定票据尺寸(210mm×140mm)
pdfGeneratorUtil.generatePdfWithCustomSize(html, response, "发票_12345", 210, 140);
问题解决方案:5个典型问题与避坑指南
问题1:中文乱码或不显示
现象:PDF中中文显示为方框或空白。原因:io.woo.htmltopdf默认支持中文,但部分环境可能缺少字体。解决方案:在HTML模板中显式指定中文字体:
css
body { font-family: SimHei, "Microsoft YaHei", sans-serif; }
问题2:样式丢失或错乱
现象:HTML页面样式正常,转PDF后样式丢失(如颜色、边框)。原因:PDF转换器对CSS支持有限,复杂样式无法解析。解决方案:
- 使用内联样式(style=”color: red;”)替代外部CSS;
- 避免CSS3特性,用基础样式(如background-color而非linear-gradient)。
问题3:分页异常(内容跨页断裂)
现象:表格或段落被分页符分割,显示不完整。解决方案:使用CSS控制分页:
css
/* 避免元素跨页 */
.no-break { page-break-inside: avoid; }
/* 强制分页 */
.break-after { page-break-after: always; }
问题4:图片不显示
现象:HTML中图片正常,PDF中图片空白或显示“broken image”。原因:图片路径为相对路径,转换器无法访问本地资源。解决方案:
- 使用绝对URL(如https://xxx.com/logo.png);
- 将图片转为Base64编码嵌入HTML:
- html
- <img src=”data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA…” />
问题5:PDF文件过大
现象:生成的PDF超过10MB,下载缓慢。原因:图片未压缩,或内容包含大量高分辨率图片。解决方案:
- 压缩图片(如将PNG转为JPG,分辨率控制在72dpi);
- 移除PDF中不必要的图片或降低质量。
扩展进阶:2个高级特性实现
特性1:动态数据填充与条件渲染
结合Thymeleaf的表达式,实现复杂动态内容(如列表循环、条件显示):
html
<!-- 循环展示多辆车信息 -->
<div th:each="car : ${cars}">
<h3 th:text="${car.brand + ' ' + car.series}">车辆名称</h3>
<p th:if="${car.price > 500000}">
<span style="color: red;">高价车</span>
</p>
<p th:unless="${car.price > 500000}">普通车</p>
</div>
Controller传参:
java
Map<String, Object> data = new HashMap<>();
data.put("cars", Arrays.asList(
new Car("路虎", "Defender", 1000000),
new Car("丰田", "卡罗拉", 150000)
));
特性2:自定义水印与页眉页脚
通过CSS的@page规则添加水印和页眉页脚:
css
@page {
/* 页眉 */
@top-center {
content: "车辆评估报告 - 第" counter(page) "页";
font-size: 10px;
color: #666;
}
/* 水印 */
background-image: url("data:image/png;base64,..."); /* Base64水印图片 */
background-position: center;
background-repeat: no-repeat;
background-size: 50%;
}