Spring Boot 集成 iText 7 实现动态 PDF 生成与电子签章:合同、报表场景实战

最近在搞电子合同项目,有个需求是用户在前端填好合同信息,后端动态生成 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,不支持中文,直接用中文会变成乱码或空白。所以必须注册中文字体文件。我踩过两个坑:

  1. .ttc 字体集合文件(比如 simsun.ttc)在注册时要指定字体索引,否则可能加载失败。
  2. 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 动态生成加电子签章这块还真绕不开它。我们这套方案现在支撑着电子合同和发票业务,每天几万份生成没啥问题。希望这些经验能帮到你。

相关推荐
孙启超1 小时前
【AI开发之Rust】第 19 课:UniFFI 导出核心能力
开发语言·后端·rust
卓怡学长1 小时前
w204基于springbo旅行社签证代理服务系统
java·spring boot·spring·intellij-idea
泡海椒1 小时前
趋势分析报表:jquick-pdf折线图PDF生成实战
java·大数据·运维·服务器·前端·pdf
ServBay1 小时前
基于Jev的浏览器Agent插件狂揽 21k star,3分钟教你解放双手
后端·aigc·ai编程
qwfy1 小时前
我要用 AI-native 架构,从零重建一套制造业 ERP/MES
vue.js·spring boot·架构
Json____2 小时前
家居装修 AI 智能咨询助手:让装修咨询从“大海捞针“变成“一问即答“
java·后端·vue3·it学习·wwwoop.com
vipxieliang2 小时前
ValidX 在微服务架构中的验证策略
java·spring boot
❀͜͡傀儡师2 小时前
20 年沉淀,CAS 8.0 重新定义企业级 SSO:适配 JDK 25 与 Spring Boot 4.1
java·开发语言·spring boot
牧瀬クリスだ2 小时前
Spring统一功能处理
java·spring boot·spring·状态模式