资讯动态

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

发布时间:2026/9/27 6:07:22 来源:尧图企业网站定制
最近在搞电子合同项目有个需求是用户在前端填好合同信息后端动态生成 PDF还要盖上公司电子章。折腾了几天踩了不少坑把 iText 7 这套方案沉淀一下。先说下业务背景常见的 PDF 生成场景无非这三类在线合同用户在前端勾选条款、填价格和期限后端根据模板把数据填进去生成一份可预览、可下载、可签署的合同 PDF。电子发票订单完成后自动生成包含税号、金额、二维码的发票 PDF盖电子发票章。财务报表从库里捞数据生成带柱状图、饼图的月报/年报可按部门维度筛选。核心诉求就两个根据数据自动渲染 PDF并且能加电子签章让文档具备法律效力。下面从技术选型说起。技术选型为啥选 iText 7Java 生态里能生成 PDF 的库有好几个我简单说下自己的使用感受不拉表格。iText 7功能是真的全AcroForm 表单填充、数字签名、PAdES、PDF/A 都支持算是 Java 里 PDF 功能的集大成者。但它的开源版是 AGPL如果你做个内部系统不对外提供软件服务没问题要是做 SaaS 对外商用强烈建议买商业授权不然有法律风险。OpenPDF是 iText 4 的分支轻量、上手快写点文本和简单表格足够但高级功能就没了比如 PAdES 数字签名、复杂 AcroForm 支持不完整。如果只是给内部工具生成个简单报告用它可以省心。Apache FOP走的是 XSL-FO 的路子适合从 XML 数据生成固定版式的出版物但对表单交互、动态签名这类支持很弱调起来也费劲。我最终选了 iText 7原因很直接动态 PDF 最核心的「模板填充」要靠 AcroFormiText 7 的PdfAcroForm模块非常成熟填完还能扁平化保证不可编辑。电子签章需要支持 PAdES 标准这块 iText 7 是官方支持OpenPDF 根本做不到。高并发场景下iText 7 的字体缓存和写入性能表现也还说得过去。Spring Boot 集成 iText先搞定中文字体引入依赖pom.xml加上 iText 7 核心模块dependencygroupIdcom.itextpdf/groupIdartifactIditext7-core/artifactIdversion7.2.5/versiontypepom/type/dependency如果需要数字签名再加上dependencygroupIdcom.itextpdf/groupIdartifactIditext-sign/artifactIdversion7.2.5/version/dependency这里提醒一下签名模块里用到了BouncyCastle记得把bcprov-jdk15on和bcpkix-jdk15on也加进来版本用 1.70 左右太高或太低都可能和 iText 版本冲突。基本用法iText 7 的标准流程是PdfWriter-PdfDocument-DocumentPdfWriterwriternewPdfWriter(newFileOutputStream(output.pdf));PdfDocumentpdfDocnewPdfDocument(writer);DocumentdocumentnewDocument(pdfDoc,PageSize.A4);document.setMargins(36,36,36,36);document.add(newParagraph(采购合同).setFontSize(20).setBold());document.add(newParagraph(合同编号HT-2025-001).setFontSize(12));document.close();中文字体是最大的坑iText 7 默认字体是 Helvetica不支持中文直接用中文会变成乱码或空白。所以必须注册中文字体文件。我踩过两个坑.ttc字体集合文件比如simsun.ttc在注册时要指定字体索引否则可能加载失败。setBold()对中文无效因为中文字体是独立文件Helvetica-Bold没有对应的中文路径。必须加载粗体字体文件如SimHei.ttf才能显示真正的粗体。正确做法是启动时把字体缓存起来ComponentpublicclassChineseFontProvider{privatestaticfinalStringFONT_PATHclasspath:/fonts/simsun.ttc;privatestaticPdfFontsongFont;PostConstructpublicvoidinit(){try{// 注意ttc 文件需要指定字体索引simsun.ttc 中第 0 个通常为宋体FontProgramfontProgramFontProgramFactory.createFont(FONT_PATH,0,false);songFontPdfFontFactory.createFont(fontProgram,PdfEncodings.IDENTITY_H);}catch(IOExceptione){thrownewRuntimeException(字体加载失败,e);}}publicstaticPdfFontgetSongFont(){returnsongFont;}}使用时document.add(newParagraph(中华人民共和国).setFont(ChineseFontProvider.getSongFont()));生成动态 PDF模板填充 代码绘制方式一用 AcroForm 模板填充强烈推荐用前端设计好的 PDF 模板然后 iText 填充这样不用在代码里排版能省不少事。先用 Adobe Acrobat 之类的工具做一个带表单域的 PDF 模板比如合同里放contractNo、customerName等文本域。然后填充publicbyte[]fillTemplate(MapString,Stringdata)throwsIOException{ByteArrayOutputStreambaosnewByteArrayOutputStream();PdfDocumentpdfDocnewPdfDocument(newPdfReader(template_contract.pdf),newPdfWriter(baos));PdfAcroFormformPdfAcroForm.getAcroForm(pdfDoc,true);data.forEach(form::setField);// 扁平化表单域变成普通文本用户改不了form.flattenFields();pdfDoc.close();returnbaos.toByteArray();}几个容易出错的地方模板里的表单控件名称不能重复且必须和代码里的 key 完全一致否则setField找不到对应的域。如果模板里本身有中文最好在制作模板时嵌入字体不然填充后中文可能显示不出来。遇到这种情况可以手动指定字体form.getField(name).setValue(value, font)。一定要扁平化flattenFields()这样生成的 PDF 无法被修改合同、发票场景必须这么干。方式二纯代码绘制表格和图表有些时候模板不好做或者数据列是动态的那就在代码里用Table对象画表格其实也不复杂TabletablenewTable(4);table.setWidthUnit(UnitValue.createPercentValue(100));table.addHeaderCell(newCell().add(newParagraph(项目).setFont(songFont)));table.addHeaderCell(newCell().add(newParagraph(数量).setFont(songFont)));table.addHeaderCell(newCell().add(newParagraph(单价).setFont(songFont)));table.addHeaderCell(newCell().add(newParagraph(金额).setFont(songFont)));for(SalesItemitem:items){table.addCell(newCell().add(newParagraph(item.getName()).setFont(songFont)));table.addCell(newCell().add(newParagraph(item.getQuantity().toString()).setFont(songFont)));table.addCell(newCell().add(newParagraph(item.getPrice().toString()).setFont(songFont)));table.addCell(newCell().add(newParagraph(item.getAmount().toString()).setFont(songFont)));}// 合并单元格CellmergednewCell(1,4).add(newParagraph(合计total).setFont(songFont));table.addCell(merged);document.add(table);图表方面iText 没有内置图表组件。我做报表的时候是先用 JFreeChart 生成一个BufferedImage然后塞进 PDFJFreeChartchartChartFactory.createBarChart(季度销售,季度,金额,dataset);BufferedImageimagechart.createBufferedImage(500,300);ImagepdfImageImageDataFactory.create(toByteArray(image));document.add(newImage(pdfImage));电子签章数字签名PAdES与可见签章电子签章的法律效力主要靠数字签名保证iText 7 对 PAdES 支持很到位实现也不难。准备证书生产用 CA 发的证书测试就用keytool生成一个 PKCS12 文件keytool-genkeypair-aliastestcert-keyalgRSA-keysize2048-storetypePKCS12\-keystorekeystore.p12-storepasschangeit-dnameCNTest, OUDev, OCompany, LCity, CCN签名代码publicbyte[]signPdf(byte[]pdfBytes,byte[]imageBytes,StringcertPath,Stringpassword)throwsException{ByteArrayOutputStreambaosnewByteArrayOutputStream();// 加载证书KeyStoreksKeyStore.getInstance(PKCS12);ks.load(newFileInputStream(certPath),password.toCharArray());Stringaliasks.aliases().nextElement();PrivateKeyprivateKey(PrivateKey)ks.getKey(alias,password.toCharArray());Certificate[]chainks.getCertificateChain(alias);// 创建签名器使用追加模式保护原文件不被篡改PdfReaderreadernewPdfReader(newByteArrayInputStream(pdfBytes));PdfSignersignernewPdfSigner(reader,baos,newStampingProperties().useAppendMode());// 设置可见签章外观PdfSignatureAppearanceappearancesigner.getSignatureAppearance();appearance.setPageRect(newRectangle(450,700,150,60));appearance.setPageNumber(1);// 添加红色公章图片ImageDataimageImageDataFactory.create(imageBytes);appearance.setSignatureGraphic(image);appearance.setRenderingMode(PdfSignatureAppearance.RenderingMode.GRAPHIC_AND_DESCRIPTION);appearance.setDescription(合同签署);// RSA 签名用 SHA-256 摘要IExternalSignatureexternalSignaturenewPrivateKeySignature(privateKey,SHA-256);// PAdES-EPES 级别signer.signDetached(newBouncyCastleDigest(),externalSignature,chain,null,null,null,0,PdfSigner.CryptoStandard.CADES);returnbaos.toByteArray();}注意签名必须是 PDF 修改的最后一步。如果先生成 PDF 再签名没问题但如果你先签名然后做任何修改签名就会失效。验证签名服务端提供验证接口法律审计时用publicbooleanverifyPdfSignature(byte[]pdfBytes)throwsException{PdfDocumentpdfDocnewPdfDocument(newPdfReader(newByteArrayInputStream(pdfBytes)));SignatureUtilsignatureUtilnewSignatureUtil(pdfDoc);ListStringnamessignatureUtil.getSignatureNames();for(Stringname:names){PdfPKCS7pkcs7signatureUtil.verifySignature(name);if(pkcs7.verifySignatureIntegrity()){System.out.println(签名有效签署者pkcs7.getSignName());returntrue;}else{System.out.println(签名无效);returnfalse;}}returnfalse;}如果 PDF 被改动过verifySignatureIntegrity()会返回false。服务化封装异步、队列和下载链接业务里 PDF 生成通常不是同步调用而是批量异步。我封装了一个服务提供同步、异步、查询状态、获取下载链接等方法。接口定义publicinterfacePdfGenerateService{byte[]generate(PdfGenerateRequestrequest);// 同步生成StringgenerateAsync(PdfGenerateRequestrequest);// 异步生成返回任务IDTaskStatusgetTaskStatus(StringtaskId);// 查询任务状态StringgetDownloadUrl(StringtaskId,Durationexpiry);// 获取临时下载链接}异步实现之前用Async加CompletableFuture.supplyAsync结果出现了线程池混乱的问题后来干脆统一用一个Async方法内部同步生成Async(pdfTaskExecutor)OverridepublicCompletableFuturebyte[]generateAsync(PdfGenerateRequestrequest){try{byte[]pdfBytestemplateFiller.fill(request);if(request.isNeedSign()){pdfBytessignerService.sign(pdfBytes,request.getSignImage());}returnCompletableFuture.completedFuture(pdfBytes);}catch(Exceptione){CompletableFuturebyte[]futurenewCompletableFuture();future.completeExceptionally(e);returnfuture;}}等等如果外层是Async方法返回CompletableFutureSpring 会把这个 future 当作返回值调用方可以直接.thenAccept()做后续处理。我们实际是这样用的Async(pdfTaskExecutor)OverridepublicCompletableFutureStringgenerateAsync(PdfGenerateRequestrequest){returnCompletableFuture.supplyAsync(()-{// 注意不需要再调用 supplyAsync直接在方法里执行});}不对Async和CompletableFuture混用容易困惑。调整一下我们直接在方法里完成生成并返回不用双重异步Async(pdfTaskExecutor)OverridepublicCompletableFutureStringgenerateAsync(PdfGenerateRequestrequest){StringtaskIdUUID.randomUUID().toString();try{byte[]pdfBytestemplateFiller.fill(request);if(request.isNeedSign()){pdfBytessignerService.sign(pdfBytes,request.getSignImage());}// 存储结果比如 MinIO、数据库storageService.storePdf(taskId,pdfBytes);taskRepository.updateStatus(taskId,TaskStatus.SUCCESS);}catch(Exceptione){taskRepository.updateStatus(taskId,TaskStatus.FAILED);}returnCompletableFuture.completedFuture(taskId);}这里细节比较多关键是异步生成的任务状态要放在外部存储比如 Redis / DB不能像某些 demo 一样用一个Map缓存生产环境会有单点丢失风险。消息队列削峰报表类场景经常是晚上批量跑几千份 PDF直接同步请求肯定扛不住。我们加了 RabbitMQ生产者把PdfGenerateRequest发到pdf.generate.queue。消费者监听队列生成后保存到 MinIO / OSS然后更新数据库状态。消费者示例ComponentpublicclassPdfGenerateConsumer{RabbitListener(queues${pdf.queue.name})publicvoidhandle(PdfGenerateRequestrequest){try{byte[]pdfBytespdfGenerateService.generate(request);StringobjectKeystorageService.upload(pdfBytes,request.getFileName());// 更新数据库任务完成保存 objectKey}catch(Exceptione){// 记录失败进入重试队列或死信队列}}}如果用 Kafka逻辑类似就是消费组和 offset 控制上稍微注意下。带过期时间的下载链接PDF 生成完不能直接暴露 MinIO 地址风险太大。我用 Redis 存一个 token 对应 objectKey然后生成一个带 token 的 URLpublicStringcreateDownloadUrl(StringobjectKey,Durationduration){StringtokenUUID.randomUUID().toString();redisTemplate.opsForValue().set(PREFIXtoken,objectKey,duration);return/pdf/download/token;}ControllerGetMapping(/pdf/download/{token})publicvoiddownload(PathVariableStringtoken,HttpServletResponseresponse){StringobjectKeydownloadUrlService.getObjectKey(token);byte[]datastorageService.download(objectKey);response.setContentType(application/pdf);response.setHeader(Content-Disposition,attachment; filenamereport.pdf);response.getOutputStream().write(data);}token 过期后getObjectKey直接抛异常客户端拿不到文件。性能和安全方面的几个经验内存优化大批量生成 PDF 时ByteArrayOutputStream很容易挤爆堆。这里有两个办法直接写临时文件生成完成后把文件路径返回给客户端而不是一次性把整个 PDF 加载到内存。用流式输出response.getOutputStream()直接写。字体对象一定要全局缓存不要每次生成重新加载。我上面的ChineseFontProvider用了静态字段但注意 Spring 管理的 Bean 是单例的所以没问题。敏感信息脱敏PDF 里的文本是可以被复制出来的所以单纯用黑色矩形遮盖不靠谱数据源头就该做脱敏。比如手机号存的时候就存138****1234或者填充时统一替换。如果非要覆盖可以用 iText 的PdfCanvas画一个黑色矩形PdfPagepagepdfDoc.getFirstPage();PdfCanvascanvasnewPdfCanvas(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 动态生成加电子签章这块还真绕不开它。我们这套方案现在支撑着电子合同和发票业务每天几万份生成没啥问题。希望这些经验能帮到你。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑