
最近在搞电子合同项目,有个需求是用户在前端填好合同信息,后端动态生成 PDF,还要盖上公司电子章。折腾了几天,踩了不少坑,把 iText 7 这套方案沉淀一下。
先说下业务背景
常见的 PDF 生成场景无非这三类:
- 在线合同:用户在前端勾选条款、填价格和期限,后端根据模板把数据填进去,生成一份可预览、可下载、可签署的合同 PDF。
- 电子发票:订单完成后,自动生成包含税号、金额、二维码的发票 PDF,盖电子发票章。
- 财务报表:从库里捞数据,生成带柱状图、饼图的月报/年报,可按部门维度筛选。
核心诉求就两个:根据数据自动渲染 PDF ,并且 能加电子签章让文档具备法律效力。下面从技术选型说起。
技术选型:为啥选 iText 7
Java 生态里能生成 PDF 的库有好几个,我简单说下自己的使用感受,不拉表格。
iText 7 功能是真的全,AcroForm 表单填充、数字签名、PAdES、PDF/A 都支持,算是 Java 里 PDF 功能的集大成者。但它的开源版是 AGPL,如果你做个内部系统(不对外提供软件服务)没问题,要是做 SaaS 对外商用,强烈建议买商业授权,不然有法律风险。
OpenPDF 是 iText 4 的分支,轻量、上手快,写点文本和简单表格足够,但高级功能就没了,比如 PAdES 数字签名、复杂 AcroForm 支持不完整。如果只是给内部工具生成个简单报告,用它可以省心。
Apache FOP 走的是 XSL-FO 的路子,适合从 XML 数据生成固定版式的出版物,但对表单交互、动态签名这类支持很弱,调起来也费劲。
我最终选了 iText 7,原因很直接:
- 动态 PDF 最核心的「模板填充」要靠 AcroForm,iText 7 的
PdfAcroForm模块非常成熟,填完还能扁平化,保证不可编辑。 - 电子签章需要支持 PAdES 标准,这块 iText 7 是官方支持,OpenPDF 根本做不到。
- 高并发场景下,iText 7 的字体缓存和写入性能表现也还说得过去。
Spring Boot 集成 iText:先搞定中文字体
引入依赖
pom.xml 加上 iText 7 核心模块:
xml
<dependency>
<groupId>com.itextpdf</groupId>
<artifactId>itext7-core</artifactId>
<version>7.2.5</version>
<type>pom</type>
</dependency>
如果需要数字签名,再加上:
xml
<dependency>
<groupId>com.itextpdf</groupId>
<artifactId>itext-sign</artifactId>
<version>7.2.5</version>
</dependency>
这里提醒一下,签名模块里用到了 BouncyCastle,记得把 bcprov-jdk15on 和 bcpkix-jdk15on 也加进来,版本用 1.70 左右,太高或太低都可能和 iText 版本冲突。
基本用法
iText 7 的标准流程是 PdfWriter -> PdfDocument -> Document:
java
PdfWriter writer = new PdfWriter(new FileOutputStream("output.pdf"));
PdfDocument pdfDoc = new PdfDocument(writer);
Document document = new Document(pdfDoc, PageSize.A4);
document.setMargins(36, 36, 36, 36);
document.add(new Paragraph("采购合同").setFontSize(20).setBold());
document.add(new Paragraph("合同编号:HT-2025-001").setFontSize(12));
document.close();
中文字体是最大的坑
iText 7 默认字体是 Helvetica,不支持中文,直接用中文会变成乱码或空白。所以必须注册中文字体文件。我踩过两个坑:
.ttc字体集合文件(比如simsun.ttc)在注册时要指定字体索引,否则可能加载失败。setBold()对中文无效,因为中文字体是独立文件,Helvetica-Bold没有对应的中文路径。必须加载粗体字体文件(如SimHei.ttf)才能显示真正的粗体。
正确做法是启动时把字体缓存起来:
java
@Component
public class ChineseFontProvider {
private static final String FONT_PATH = "classpath:/fonts/simsun.ttc";
private static PdfFont songFont;
@PostConstruct
public void init() {
try {
// 注意:ttc 文件需要指定字体索引,simsun.ttc 中第 0 个通常为宋体
FontProgram fontProgram = FontProgramFactory.createFont(FONT_PATH, 0, false);
songFont = PdfFontFactory.createFont(fontProgram, PdfEncodings.IDENTITY_H);
} catch (IOException e) {
throw new RuntimeException("字体加载失败", e);
}
}
public static PdfFont getSongFont() {
return songFont;
}
}
使用时:
java
document.add(new Paragraph("中华人民共和国").setFont(ChineseFontProvider.getSongFont()));
生成动态 PDF:模板填充 + 代码绘制
方式一:用 AcroForm 模板填充
强烈推荐用前端设计好的 PDF 模板,然后 iText 填充,这样不用在代码里排版,能省不少事。
先用 Adobe Acrobat 之类的工具做一个带表单域的 PDF 模板,比如合同里放 contractNo、customerName 等文本域。
然后填充:
java
public byte[] fillTemplate(Map<String, String> data) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
PdfDocument pdfDoc = new PdfDocument(new PdfReader("template_contract.pdf"), new PdfWriter(baos));
PdfAcroForm form = PdfAcroForm.getAcroForm(pdfDoc, true);
data.forEach(form::setField);
// 扁平化,表单域变成普通文本,用户改不了
form.flattenFields();
pdfDoc.close();
return baos.toByteArray();
}
几个容易出错的地方:
- 模板里的表单控件名称不能重复,且必须和代码里的 key 完全一致,否则
setField找不到对应的域。 - 如果模板里本身有中文,最好在制作模板时嵌入字体,不然填充后中文可能显示不出来。遇到这种情况,可以手动指定字体:
form.getField(name).setValue(value, font)。 - 一定要扁平化(
flattenFields()),这样生成的 PDF 无法被修改,合同、发票场景必须这么干。
方式二:纯代码绘制表格和图表
有些时候模板不好做,或者数据列是动态的,那就在代码里用 Table 对象画表格,其实也不复杂:
java
Table table = new Table(4);
table.setWidthUnit(UnitValue.createPercentValue(100));
table.addHeaderCell(new Cell().add(new Paragraph("项目").setFont(songFont)));
table.addHeaderCell(new Cell().add(new Paragraph("数量").setFont(songFont)));
table.addHeaderCell(new Cell().add(new Paragraph("单价").setFont(songFont)));
table.addHeaderCell(new Cell().add(new Paragraph("金额").setFont(songFont)));
for (SalesItem item : items) {
table.addCell(new Cell().add(new Paragraph(item.getName()).setFont(songFont)));
table.addCell(new Cell().add(new Paragraph(item.getQuantity().toString()).setFont(songFont)));
table.addCell(new Cell().add(new Paragraph(item.getPrice().toString()).setFont(songFont)));
table.addCell(new Cell().add(new Paragraph(item.getAmount().toString()).setFont(songFont)));
}
// 合并单元格
Cell merged = new Cell(1, 4).add(new Paragraph("合计:" + total).setFont(songFont));
table.addCell(merged);
document.add(table);
图表方面,iText 没有内置图表组件。我做报表的时候是先用 JFreeChart 生成一个 BufferedImage,然后塞进 PDF:
java
JFreeChart chart = ChartFactory.createBarChart("季度销售", "季度", "金额", dataset);
BufferedImage image = chart.createBufferedImage(500, 300);
Image pdfImage = ImageDataFactory.create(toByteArray(image));
document.add(new Image(pdfImage));
电子签章:数字签名(PAdES)与可见签章
电子签章的法律效力主要靠数字签名保证,iText 7 对 PAdES 支持很到位,实现也不难。
准备证书
生产用 CA 发的证书,测试就用 keytool 生成一个 PKCS12 文件:
bash
keytool -genkeypair -alias testcert -keyalg RSA -keysize 2048 -storetype PKCS12 \
-keystore keystore.p12 -storepass changeit -dname "CN=Test, OU=Dev, O=Company, L=City, C=CN"
签名代码
java
public byte[] signPdf(byte[] pdfBytes, byte[] imageBytes, String certPath, String password) throws Exception {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
// 加载证书
KeyStore ks = KeyStore.getInstance("PKCS12");
ks.load(new FileInputStream(certPath), password.toCharArray());
String alias = ks.aliases().nextElement();
PrivateKey privateKey = (PrivateKey) ks.getKey(alias, password.toCharArray());
Certificate[] chain = ks.getCertificateChain(alias);
// 创建签名器,使用追加模式,保护原文件不被篡改
PdfReader reader = new PdfReader(new ByteArrayInputStream(pdfBytes));
PdfSigner signer = new PdfSigner(reader, baos, new StampingProperties().useAppendMode());
// 设置可见签章外观
PdfSignatureAppearance appearance = signer.getSignatureAppearance();
appearance.setPageRect(new Rectangle(450, 700, 150, 60));
appearance.setPageNumber(1);
// 添加红色公章图片
ImageData image = ImageDataFactory.create(imageBytes);
appearance.setSignatureGraphic(image);
appearance.setRenderingMode(PdfSignatureAppearance.RenderingMode.GRAPHIC_AND_DESCRIPTION);
appearance.setDescription("合同签署");
// RSA 签名,用 SHA-256 摘要
IExternalSignature externalSignature = new PrivateKeySignature(privateKey, "SHA-256");
// PAdES-EPES 级别
signer.signDetached(new BouncyCastleDigest(), externalSignature, chain, null, null, null, 0, PdfSigner.CryptoStandard.CADES);
return baos.toByteArray();
}
注意,签名必须是 PDF 修改的最后一步。如果先生成 PDF 再签名没问题,但如果你先签名然后做任何修改,签名就会失效。
验证签名
服务端提供验证接口,法律审计时用:
java
public boolean verifyPdfSignature(byte[] pdfBytes) throws Exception {
PdfDocument pdfDoc = new PdfDocument(new PdfReader(new ByteArrayInputStream(pdfBytes)));
SignatureUtil signatureUtil = new SignatureUtil(pdfDoc);
List<String> names = signatureUtil.getSignatureNames();
for (String name : names) {
PdfPKCS7 pkcs7 = signatureUtil.verifySignature(name);
if (pkcs7.verifySignatureIntegrity()) {
System.out.println("签名有效,签署者:" + pkcs7.getSignName());
return true;
} else {
System.out.println("签名无效");
return false;
}
}
return false;
}
如果 PDF 被改动过,verifySignatureIntegrity() 会返回 false。
服务化封装:异步、队列和下载链接
业务里 PDF 生成通常不是同步调用,而是批量异步。我封装了一个服务,提供同步、异步、查询状态、获取下载链接等方法。
接口定义:
java
public interface PdfGenerateService {
byte[] generate(PdfGenerateRequest request); // 同步生成
String generateAsync(PdfGenerateRequest request); // 异步生成,返回任务ID
TaskStatus getTaskStatus(String taskId); // 查询任务状态
String getDownloadUrl(String taskId, Duration expiry); // 获取临时下载链接
}
异步实现
之前用 @Async 加 CompletableFuture.supplyAsync 结果出现了线程池混乱的问题,后来干脆统一用一个 @Async 方法,内部同步生成:
java
@Async("pdfTaskExecutor")
@Override
public CompletableFuture<byte[]> generateAsync(PdfGenerateRequest request) {
try {
byte[] pdfBytes = templateFiller.fill(request);
if (request.isNeedSign()) {
pdfBytes = signerService.sign(pdfBytes, request.getSignImage());
}
return CompletableFuture.completedFuture(pdfBytes);
} catch (Exception e) {
CompletableFuture<byte[]> future = new CompletableFuture<>();
future.completeExceptionally(e);
return future;
}
}
等等,如果外层是 @Async,方法返回 CompletableFuture,Spring 会把这个 future 当作返回值,调用方可以直接 .thenAccept() 做后续处理。我们实际是这样用的:
java
@Async("pdfTaskExecutor")
@Override
public CompletableFuture<String> generateAsync(PdfGenerateRequest request) {
return CompletableFuture.supplyAsync(() -> {
// 注意:不需要再调用 supplyAsync,直接在方法里执行
});
}
不对,@Async 和 CompletableFuture 混用容易困惑。调整一下,我们直接在方法里完成生成并返回,不用双重异步:
java
@Async("pdfTaskExecutor")
@Override
public CompletableFuture<String> generateAsync(PdfGenerateRequest request) {
String taskId = UUID.randomUUID().toString();
try {
byte[] pdfBytes = templateFiller.fill(request);
if (request.isNeedSign()) {
pdfBytes = signerService.sign(pdfBytes, request.getSignImage());
}
// 存储结果,比如 MinIO、数据库
storageService.storePdf(taskId, pdfBytes);
taskRepository.updateStatus(taskId, TaskStatus.SUCCESS);
} catch (Exception e) {
taskRepository.updateStatus(taskId, TaskStatus.FAILED);
}
return CompletableFuture.completedFuture(taskId);
}
这里细节比较多,关键是异步生成的任务状态要放在外部存储(比如 Redis / DB),不能像某些 demo 一样用一个 Map 缓存,生产环境会有单点丢失风险。
消息队列削峰
报表类场景经常是晚上批量跑几千份 PDF,直接同步请求肯定扛不住。我们加了 RabbitMQ:
- 生产者把
PdfGenerateRequest发到pdf.generate.queue。 - 消费者监听队列,生成后保存到 MinIO / OSS,然后更新数据库状态。
消费者示例:
java
@Component
public class PdfGenerateConsumer {
@RabbitListener(queues = "${pdf.queue.name}")
public void handle(PdfGenerateRequest request) {
try {
byte[] pdfBytes = pdfGenerateService.generate(request);
String objectKey = storageService.upload(pdfBytes, request.getFileName());
// 更新数据库:任务完成,保存 objectKey
} catch (Exception e) {
// 记录失败,进入重试队列或死信队列
}
}
}
如果用 Kafka,逻辑类似,就是消费组和 offset 控制上稍微注意下。
带过期时间的下载链接
PDF 生成完不能直接暴露 MinIO 地址,风险太大。我用 Redis 存一个 token 对应 objectKey,然后生成一个带 token 的 URL:
java
public String createDownloadUrl(String objectKey, Duration duration) {
String token = UUID.randomUUID().toString();
redisTemplate.opsForValue().set(PREFIX + token, objectKey, duration);
return "/pdf/download/" + token;
}
Controller:
java
@GetMapping("/pdf/download/{token}")
public void download(@PathVariable String token, HttpServletResponse response) {
String objectKey = downloadUrlService.getObjectKey(token);
byte[] data = storageService.download(objectKey);
response.setContentType("application/pdf");
response.setHeader("Content-Disposition", "attachment; filename=report.pdf");
response.getOutputStream().write(data);
}
token 过期后,getObjectKey 直接抛异常,客户端拿不到文件。
性能和安全方面的几个经验
内存优化
大批量生成 PDF 时,ByteArrayOutputStream 很容易挤爆堆。这里有两个办法:
- 直接写临时文件,生成完成后把文件路径返回给客户端,而不是一次性把整个 PDF 加载到内存。
- 用流式输出,
response.getOutputStream()直接写。
字体对象一定要全局缓存,不要每次生成重新加载。我上面的 ChineseFontProvider 用了静态字段,但注意 Spring 管理的 Bean 是单例的,所以没问题。
敏感信息脱敏
PDF 里的文本是可以被复制出来的,所以单纯用黑色矩形遮盖不靠谱,数据源头就该做脱敏。比如手机号存的时候就存 138****1234,或者填充时统一替换。
如果非要覆盖,可以用 iText 的 PdfCanvas 画一个黑色矩形:
java
PdfPage page = pdfDoc.getFirstPage();
PdfCanvas canvas = new PdfCanvas(page);
canvas.setFillColor(ColorConstants.BLACK);
canvas.rectangle(x, y, width, height);
canvas.fill();
但记住,这只是视觉隐藏,不是安全措施。
模板注入
从用户输入填充表单时,要对特殊字符做转义。比如用户填了 <script>,虽然 PDF 不会执行脚本,但可能破坏模板结构导致渲染异常。最常见的做法是只允许白名单字符,或者替换 XML 特殊字符。
签名和加密的顺序
签名之后不要再加密或修改 PDF,否则签名失效。如果必须限制用户复制和打印,建议在签名前完成加密设置,但要注意加密和签名组合可能带来兼容性问题。我们实际项目中是签名后不加密,由外部系统控制访问权限。
关于 AGPL 和商业授权
最后说下许可证。iText 7 是 AGPL,如果你们的产品是对外提供的 SaaS,必须买商业授权,否则代码要全部开源。如果只是公司内部系统(不对外提供软件服务),AGPL 是允许的。这个一定要跟法务确认清楚,别踩坑。
最后说两句
这次把 iText 7 从模板填充到数字签名走了一遍,整体感觉是文档多、坑也不少,但只要是 Java 生态,PDF 动态生成加电子签章这块还真绕不开它。我们这套方案现在支撑着电子合同和发票业务,每天几万份生成没啥问题。希望这些经验能帮到你。