使用SpringBoot + Thymeleaf + iText实现动态PDF导出
使用SpringBoot + Thymeleaf + iText实现动态PDF导出
1.前端模版代码,需要注意,iText有很多高级样式兼用性不好,需要自己试错:
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head><meta charset="UTF-8"/><title>模版文件</title><style>* {padding: 0;margin: 0;box-sizing: border-box;}li {list-style: none;}@page {size: A4;margin: 0.5cm;}html, body {width: 100%; /* A4宽度 */font-family: SimSun, Arial, sans-serif;}/* 容器样式 */.home, .profile, .clazzHourCert, .records {width: 90%;margin: 0 auto;}.records {margin-top: 90px;}h1, h2 {font-weight: normal;}.studyProfileNo {text-align: right;width: 100%;margin-top: 70px;}.home h1 {margin-top: 380px;text-align: center;width: 100%;}.info-container {margin: 250px auto 0;width: 100%;position: relative;}.info-row {margin-bottom: 35px;text-align: left;position: relative;left: 35%;}.spacing {/*调整汉字间距*/letter-spacing: 2em;font-style: normal;}table {width: 100%;/*设置框线*/border-collapse: collapse;/*固定列宽*/table-layout: fixed;}.profile table {text-align: center;}.profile table caption {margin-bottom: 25px;margin-top: 70px;}.profile table tr {height: 25px;}td {border: 1px solid black;padding: 5px;}.clazzHourCert .title {font-size: 20px;text-align: center;margin-top: 90px;}.clazzHourCert .no {text-align: left;margin-top: 60px;}.clazzHourCert table {margin-top: 10px;text-align: left;}.clazzHourCert td {width: 40%;height: 40px;}/*最后一行的表格*/.clazzHourCert table tr:last-child {height: 220px;line-height: 2.5;/*垂直底部对齐*/vertical-align: bottom;}.studyRecords table, .faceRecords table {/* 强制列宽固定 */table-layout: fixed;text-align: center;}/*span转成块才能设置宽高*/.studyRecords .name {display: inline-block;width: 50%;margin-bottom: 15px;}.exam header {text-align: center;}.exam p {margin-top: 15px;}.exam .info {margin-top: 15px;}.exam td {border: 0;padding: 0;text-align: left;}.home {position: relative;}/*电子签章*/.home .seal {position: absolute;bottom: -50px;left: 120px;}.profile .seal {position: relative;left: 350px;top: 250px;}.clazzHourCert .seal {position: relative;right: 100px;top: 80px;}.faceRecords .seal {position: absolute;right: 150px;bottom: 0;}.studyRecords {position: relative;}.studyRecords .seal {position: absolute;left: 450px;top: 750px;}.exam {position: relative;}.exam .seal {position: absolute;left: 450px;top: 750px;}</style>
</head>
<body>
<!--首页-->
<div class="home"><p class="studyProfileNo">档案编号:<span th:text="${data.studyProfileNo}"></span></p><h1>学员学习档案</h1><div class="info-container"><div class="info-row"><span class="label"><em class="spacing">姓</em>名:</span><span class="content" th:text="${data.studentName}"></span></div><div class="info-row"><span class="label">身份证号:</span><span class="content" th:text="${data.idCard}"></span></div><div class="info-row"><span class="label">生成日期:</span><span class="content" th:text="${data.curDate}"></span></div><div class="info-row">平台名称(盖章):<img class="seal" src="" alt="电子签章" width="170"/></div></div>
</div><div style="page-break-before: always;"></div><!--学员学习档案-->
<div class="profile"><table><caption><h2>学员学习档案</h2></caption><colgroup><col style="width: 20%;"/><col style="width: 20%;"/><col style="width: 10%;"/><col style="width: 10%;"/><col style="width: 10%;"/><col style="width: 10%;"/><col style="width: 20%;"/></colgroup><tbody><tr><td colspan="7">注册信息</td></tr><tr><td>姓名</td><td th:text="${data.studentName}"></td><td>性别</td><td th:text="${data.sex}"></td><td>年龄</td><td th:text="${data.age}"></td><td rowspan="5"><img th:src="${data.personnelImg}" src="" alt="暂无图片" width="auto" height="125px"/></td></tr><tr><td>联系电话</td><td th:text="${data.phone}"></td><td colspan="2">身份证号</td><td colspan="2" style="font-size: 14px" th:text="${data.idCard}"></td></tr><tr><td>学历</td><td th:text="${data.education}"></td><td colspan="2">职务/职称</td><td colspan="2" th:text="${data.post}"></td></tr><tr><td>部门</td><td th:text="${data.department}"></td><td colspan="2">工种</td><td colspan="2" th:text="${data.craft}"></td></tr><tr><td>平台注册时间</td><td th:text="${data.registerTime}"></td><td colspan="2">累计课时</td><td colspan="2" th:text="${data.cumulativeClazzHour}"></td></tr><tr class="large-height" height="50px"><td height="50px">所属单位</td><td colspan="6" height="50px" th:text="${data.companyName}"></td></tr><tr><td colspan="7">班级信息</td></tr><tr><td>班级名称</td><td colspan="6" th:text="${data.clazzName}"></td></tr><tr><td>班级编号</td><td colspan="2" th:text="${data.clazzNo}"></td><td colspan="2">班级期次</td><td colspan="2" th:text="${data.clazzIssue}"></td></tr><tr><td>班级期限</td><td colspan="2" th:text="${data.clazzDeadline}"></td><td colspan="2">起止学习时间</td><td colspan="2" th:text="${data.studyDeadline}"></td></tr><tr><td>学习方式</td><td colspan="2" th:text="${data.studyWay}"></td><td colspan="2">课程形式</td><td colspan="2" th:text="${data.courseForm}"></td></tr></tbody></table><img class="seal" src="" alt="电子签章" width="170"/></div><div style="page-break-before: always;"></div><!--学时证明-->
<div class="clazzHourCert"><p class="title">安全教育职业培训平台学时证明</p><p class="no">证书编号: <span th:text="${data.clazzHourProve.clazzHourCertNo}"></span></p><table><tbody><tr><td>姓名</td><td><span th:text="${data.studentName}"></span></td></tr><tr><td>证件类型</td><td>身份证</td></tr><tr><td>证件编号</td><td><span th:text="${data.idCard}"></span></td></tr><tr><td>企业名称</td><td><span th:text="${data.companyName}"></span></td></tr><tr><td>班级名称</td><td><span th:text="${data.clazzName}"></span></td></tr><tr><td>培训日期</td><td><span th:text="${data.clazzHourProve.trainTime}"></span></td></tr><tr><td>培训类型</td><td><span th:text="${data.clazzHourProve.trainType}"></span></td></tr><tr><td>视频学习时长</td><td><span th:text="${data.clazzHourProve.videoLearningTime}"></span></td></tr><tr><td>合计在线学习时长</td><td><span th:text="${data.clazzHourProve.onlineLearningTotalTime}"></span></td></tr><tr><td>培训单位:(盖章)<br/>日期:<span th:text="${data.clazzHourProve.curDate}"></span></td><td>平台:(盖章)<img class="seal" src="" alt="电子签章" width="170px"/><br/>日期:<span th:text="${data.clazzHourProve.curDate}"></span></td></tr></tbody></table>
</div><div style="page-break-before: always;"></div><div class="records" th:each="item, stat : ${data.studyRecordsList}"><!--人脸验证记录--><div class="faceRecords"><table><colgroup><col style="width: 20%;"/><col style="width: 30%;"/><col style="width: 10%;"/><col style="width: 10%;"/><col style="width: 30%;"/></colgroup><tr><td colspan="5">学习记录</td></tr><tr><td>课程名称</td><td colspan="4" th:text="${item.studyRecords.courseName}"></td></tr><tr><td>要求课时</td><td th:text="${item.studyRecords.requireClazzHour}"></td><td colspan="2">已学课时</td><td th:text="${item.studyRecords.studyClazzHour}"></td></tr><tr><td>是否完成</td><td th:text="${item.studyRecords.completeStatus}"></td><td colspan="2">学时证明</td><td th:text="${item.studyRecords.clazzHourCertNo}"></td></tr><tr><td>到课率</td><td th:text="${item.studyRecords.clazzAttendance}"></td><td colspan="2">课程考试正确率</td><td th:text="${item.studyRecords.examCorrectAttendance}"></td></tr><tr><td>考试成绩</td><td th:text="${item.studyRecords.examScore}"></td><td colspan="2">是否合格</td><td th:text="${item.studyRecords.passStatus}"></td></tr><tr><td rowspan="6">人脸验证记录</td><td colspan="4">共进行<span th:text="${item.studyRecords.faceVerifyTotal}"></span>次人脸认证,成功<span th:text="${item.studyRecords.faceVerifySuccessCount}"></span>次,失败<span th:text="${item.studyRecords.faceVerifyFailCount}"></span>次。</td></tr><!--如果是第5行就增加相对定位,因为第五行第二列的签章设置相对定位了--><tr th:each="faceVerify, iterStat : ${item.studyRecords.faceVerifyList}" th:style="${iterStat.count == 5} ? 'position: relative;'"><td colspan="2"><span th:text="${faceVerify != null} ? '随机照片' + ${iterStat.count}"></span><br/><span th:text="${faceVerify != null} ? ${faceVerify.snapshotTime}"></span><br/><span th:text="${faceVerify != null} ? '课件:' + ${faceVerify.coursewareName}"></span></td><td colspan="2"><!--如果是第5行就增加这个图片,不是的话不加--><img th:if="${iterStat.count == 5}" class="seal" src="" alt="电子签章" width="170px"/><img th:src="${faceVerify != null} ? ${faceVerify.snapshotFile}" src="" alt="暂无图片" width="auto" height="125px"/></td></tr><tr><td>考试试卷名称</td><td colspan="4" th:text="${item.studentExamPaper.examPaperName}"></td></tr></table></div><div style="page-break-before: always;"></div><!--课程章节学习记录--><div class="studyRecords"><p><span class="name" th:text="'姓名:' + ${data.studentName}"></span><span class="idcardNo" th:text="'身份证号:' + ${data.idcard}"></span></p><table><!--每列的固定宽度--><colgroup><col style="width: 15%;"/><col style="width: 60%;"/><col style="width: 10%;"/><col style="width: 15%;"/></colgroup><tr><td>课程名称</td><td colspan="3" th:text="${item.courseCourseware.courseName}"></td></tr><tr><td>序号</td><td>课程内容</td><td>课时</td><td>讲师</td></tr><!--遍历课件列表--><tr th:each="courseware, iterStat : ${item.courseCourseware.coursewareList}"><td th:text="${iterStat.count}"></td><td th:text="${courseware.coursewareName}"></td><td th:text="${courseware.courseHour}"></td><td th:text="${courseware.teacherName}"></td></tr></table><img class="seal" src="" alt="电子签章" width="170"/></div><!--PDF手动分页--><div style="page-break-before: always;"></div><!--试卷--><div class="exam"><header><h1 th:text="${item.studentExamPaper.examPaperName}"></h1><p>(满分:<span th:text="${item.studentExamPaper.fullMark}"></span>分)</p><div class="info"><table><colgroup><col style="width: 40%;"/><col style="width: 40%;"/><col style="width: 20%;"/></colgroup><tr><td>班级名称:<span th:text="${data.clazzName}"></span></td><td>姓名:<span th:text="${data.studentName}"></span></td><td>成绩:<span th:text="${item.studentExamPaper.grade}"></span></td></tr><tr><td>考试时间:<span th:text="${item.studentExamPaper.examTime}"></span></td><td>判卷人:<span th:text="${item.studentExamPaper.judge}"></span></td></tr></table></div></header><main><section th:each="question, iterStat : ${item.studentExamPaper.examPaperItems}"><p><span th:text="${question.questionContent}"></span></p><ul><li th:each="option : ${question.options}" th:text="${option}"></li></ul></section></main><img class="seal" src="" alt="电子签章" width="170"/></div><!-- 判断是否为最后一个元素,避免最后一项后多余分页 --><div th:if="${!stat.last}" style="page-break-after: always;"></div>
</div></body>
</html>
2.maven依赖
<dependency><groupId>org.xhtmlrenderer</groupId><artifactId>flying-saucer-pdf</artifactId><version>9.1.22</version></dependency><dependency><groupId>com.itextpdf</groupId><artifactId>itextpdf</artifactId><version>5.5.13.3</version></dependency>
3.PDF工具类,将动态数据渲染到PDF模版中,并保存到磁盘
import com.itextpdf.text.Image;
import com.itextpdf.text.Rectangle;
import com.itextpdf.text.pdf.PdfContentByte;
import com.itextpdf.text.pdf.PdfReader;
import com.itextpdf.text.pdf.PdfStamper;
import com.lowagie.text.DocumentException;
import com.lowagie.text.pdf.BaseFont;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import org.thymeleaf.templatemode.TemplateMode;
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver;
import org.xhtmlrenderer.pdf.ITextRenderer;import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;public class PDFUtil {private static final TemplateEngine templateEngine;static {ClassLoaderTemplateResolver templateResolver = new ClassLoaderTemplateResolver();templateResolver.setPrefix("template/");templateResolver.setSuffix(".html");templateResolver.setTemplateMode(TemplateMode.HTML);templateResolver.setCharacterEncoding("UTF-8");templateResolver.setCacheable(true);templateEngine = new TemplateEngine();templateEngine.setTemplateResolver(templateResolver);}/*** 获取PDF总页数*/public static int getPdfPageCount(String pdfPath) throws IOException {PdfReader reader = new PdfReader(pdfPath);int pages = reader.getNumberOfPages();reader.close();return pages;}/*** 在 PDF 文档中插入骑缝章。** @param inputPdfPath 输入的 PDF 文件路径* @param outputPdfPath 输出的 PDF 文件路径* @param sealImages 骑缝章图片数组(每个元素对应一页)* @throws IOException 如果读取或写入文件时发生错误* @throws DocumentException 如果操作 PDF 时发生错误*/public static void addRidingSeal(String inputPdfPath, String outputPdfPath, BufferedImage[] sealImages)throws IOException, com.itextpdf.text.DocumentException {// 读取输入的 PDF 文件PdfReader reader = new PdfReader(inputPdfPath);int numberOfPages = reader.getNumberOfPages();// 确保骑缝章图片的数量与 PDF 页面数量一致if (sealImages.length != numberOfPages) {throw new IllegalArgumentException("骑缝章图片数量与 PDF 页面数量不匹配!");}// 创建输出的 PDF 文件PdfStamper stamper = new PdfStamper(reader, Files.newOutputStream(Paths.get(outputPdfPath)));// 循环每一页,将对应的骑缝章插入到页面中for (int i = 1; i <= numberOfPages; i++) {// 获取当前页的内容字节流PdfContentByte content = stamper.getOverContent(i);// 将 BufferedImage 转换为 iText 的 Image 对象File tempFile = File.createTempFile("sealPart", ".png");ImageIO.write(sealImages[i - 1], "png", tempFile);Image sealImage = Image.getInstance(tempFile.getAbsolutePath());float scaleDownFactor = 0.72f;// 电子签章缩放倍数// 获取原始 BufferedImage 的宽高(以像素为单位)float originalWidthPx = sealImages[i - 1].getWidth() * scaleDownFactor;float originalHeightPx = sealImages[i - 1].getHeight() * scaleDownFactor;// 设置骑缝章的实际宽高(保持比例不变)sealImage.scaleAbsolute(originalWidthPx, originalHeightPx);// 设置骑缝章的位置Rectangle pageSize = reader.getPageSize(i);float pageWidth = pageSize.getWidth();float pageHeight = pageSize.getHeight();// 计算骑缝章的位置(右侧边缘,垂直居中)float x = pageWidth - sealImage.getScaledWidth(); // 右侧边缘float y = (pageHeight - sealImage.getScaledHeight()) / 2; // 垂直居中// 添加骑缝章到当前页sealImage.setAbsolutePosition(x, y);content.addImage(sealImage);// 删除临时文件tempFile.deleteOnExit();}// 关闭资源stamper.close();reader.close();}/*** 将HTML转换为PDF* @param htmlContent HTML内容* @param outputPath 输出路径* @param fileName 文件名称* @throws IOException* @throws DocumentException*/public static void convertHtmlToPdf(String htmlContent, String outputPath, String fileName)throws IOException, DocumentException {ITextRenderer renderer = new ITextRenderer();// 加载中文字体try (InputStream fontStream = PDFUtil.class.getClassLoader().getResourceAsStream("fonts/simsun.ttc")) {if (fontStream == null) {throw new IOException("无法找到字体文件");}Path tempFontFile = Files.createTempFile("simsun", ".ttc");tempFontFile.toFile().deleteOnExit();Files.copy(fontStream, tempFontFile, StandardCopyOption.REPLACE_EXISTING);renderer.getFontResolver().addFont(tempFontFile.toString(), BaseFont.IDENTITY_H, BaseFont.EMBEDDED);}renderer.setDocumentFromString(htmlContent);renderer.layout();try (OutputStream os = Files.newOutputStream(Paths.get(outputPath + fileName))) {renderer.createPDF(os);}}public static String processTemplateWithData(Object dataModel, String templateName) throws IOException {Context context = new Context();context.setVariable("data", dataModel); // 将数据模型设置到Thymeleaf上下文中return templateEngine.process(templateName, context);}}
测试调用:
try {// 使用数据对象处理HTML模板,模版放到resources/template文件夹中,且必须是HTML文件String processedHtml = PDFUtil.processTemplateWithData(studentProfile, "study_profile");// 转换为PDFPDFUtil.convertHtmlToPdf(processedHtml, "/path/to/save/", "newfile.pdf");System.out.println("PDF生成成功!");} catch (Exception e) {e.printStackTrace();}
4.图片处理工具类,用于处理骑缝章
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;public class ImageUtil {/*** 调整图片大小以达到目标宽度,并保持宽高比。** @param inputFile 输入图片文件路径* @param outputFile 输出调整大小后的图片文件路径* @param targetWidth 目标宽度* @throws IOException 如果读取或写入图片时发生错误*/public static void resizeImage(String inputFile, String outputFile, int targetWidth) throws IOException {// 读取原始图片BufferedImage originalImage = ImageIO.read(new File(inputFile));if (originalImage == null) {throw new IOException("无法读取图片文件: " + inputFile);}// 获取原始尺寸int originalWidth = originalImage.getWidth();int originalHeight = originalImage.getHeight();// 计算新的高度,保持原始宽高比int targetHeight = (int) (targetWidth * ((double) originalHeight / originalWidth));// 创建一个新的空白图像,用于绘制调整大小后的图像BufferedImage resizedImage = new BufferedImage(targetWidth, targetHeight, originalImage.getType());Graphics2D g2d = resizedImage.createGraphics();// 使用抗锯齿和高质量的缩放算法绘制调整大小后的图像g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);g2d.drawImage(originalImage, 0, 0, targetWidth, targetHeight, null);// 释放资源g2d.dispose();// 写入输出文件String formatName = inputFile.substring(inputFile.lastIndexOf(".") + 1);ImageIO.write(resizedImage, formatName, new File(outputFile));}/*** 图片分割* @param imagePath 图片路径* @param parts 切割数量* @return 切割后的图片数组* @throws Exception*/public static BufferedImage[] splitImage2(String imagePath, int parts) throws Exception {// 加载图片BufferedImage originalImage = ImageIO.read(new File(imagePath));int totalWidth = originalImage.getWidth();int height = originalImage.getHeight();System.out.println(totalWidth + " : " + height);// 创建分割后的图片数组BufferedImage[] splitImages = new BufferedImage[parts];// 计算每段的基础宽度和剩余宽度int basePartWidth = totalWidth / parts; // 每段的基础宽度int remainder = totalWidth % parts; // 剩余的宽度for (int i = 0; i < parts; i++) {// 动态计算当前段的宽度int partWidth = basePartWidth + (i < remainder ? 1 : 0);// 计算当前段的起始位置int startX = i * basePartWidth + Math.min(i, remainder);// 截取当前段splitImages[i] = originalImage.getSubimage(startX, 0, partWidth, height);}return splitImages;}}
5.向PDF中插入骑缝章
String inputPdf = "/path/to/input/file.pdf";String outputPdf = "/path/to/out/newfile.pdf";String sealImage = "/path/to/seal/seal.png";int parts = PDFUtil.getPdfPageCount(inputPdf);BufferedImage[] bufferedImages = ImageUtil.splitImage(sealImage, parts);PDFUtil.addRidingSeal(inputPdf, outputPdf, bufferedImages);
6.效果展示
不知道为什么,PDF插入骑缝章的时候,即便是给骑缝章设置了固定宽高,到PDF后依然会失调,所以手动设置了宽高的缩放比为0.72。至此,使用SpringBoot + Thymeleaf + iText实现动态PDF导出完成。
服务器部署后发现在插入骑缝章的时候,JVM崩溃,内存方面的,怀疑是iText的bug或者其他情况,骑缝章这块改成Apache的PDFBox了,然后正常,大体逻辑不变。
<!-- PDFBox 核心库 --><dependency><groupId>org.apache.pdfbox</groupId><artifactId>pdfbox</artifactId><version>2.0.27</version></dependency><!-- PDFBox 工具库(包含图像处理等实用功能) --><dependency><groupId>org.apache.pdfbox</groupId><artifactId>pdfbox-tools</artifactId><version>2.0.27</version></dependency>
PDFUtil工具类
public static void addRidingSeal2(String inputPdfPath, String outputPdfPath, BufferedImage[] sealImages)throws IOException {// 参数校验if (inputPdfPath == null || outputPdfPath == null || sealImages == null) {throw new IllegalArgumentException("输入参数不能为null");}try (PDDocument document = PDDocument.load(new File(inputPdfPath))) {// 1. 读取PDFint numberOfPages = document.getNumberOfPages();// 2. 验证骑缝章数量if (sealImages.length != numberOfPages) {throw new IllegalArgumentException(String.format("骑缝章图片数量(%d)与PDF页面数量(%d)不匹配",sealImages.length, numberOfPages));}// 3. 处理每一页for (int i = 0; i < numberOfPages; i++) {BufferedImage sealImg = sealImages[i];PDPage page = document.getPage(i);// 3.1 校验图像有效性if (sealImg == null || sealImg.getWidth() <= 0 || sealImg.getHeight() <= 0) {throw new IllegalArgumentException(String.format("第%d页的骑缝章图像无效", i + 1));}// 3.2 将图像转为PDFBox的PDImageXObjectPDImageXObject pdImage;try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {ImageIO.write(sealImg, "png", baos);pdImage = PDImageXObject.createFromByteArray(document, baos.toByteArray(), "seal");}// 3.3 设置图像大小和位置float scale = 0.72f;float width = sealImg.getWidth() * scale;float height = sealImg.getHeight() * scale;float x = page.getMediaBox().getWidth() - width; // 右侧float y = (page.getMediaBox().getHeight() - height) / 2; // 垂直居中// 3.4 添加到PDFtry (PDPageContentStream contentStream = new PDPageContentStream(document, page, PDPageContentStream.AppendMode.APPEND, true)) {contentStream.drawImage(pdImage, x, y, width, height);}}// 4. 保存PDFdocument.save(outputPdfPath);}
}