Advertisement

Java实现自动生成PDF+电子签章

阅读量:

最近因工作需要,调研了一下java怎么用代码方式生成带有电子签名和签章的PDF文件,发现网上已经有很多例子了,也参考了很多。发现其中有些细节都没有提到,因此写个博客记录一下。

本文会比较全面,从电子签章封面图片的制作,到电子签名证书的生成,再到绘制PDF以及最后对PDF文件加上电子签章等步骤。

经过测试,生成后的PDF电子签章在Windows系统中支持WPS、福昕、Adobe Acrobat等软件识别。Mac系统WPS无电子证书功能,福昕可以识别,Adobe Acrobat需要花钱就没测。移动端也只有福昕可以识别到电子签章。

相关工具类jar包版本:
1.PDF工具jar包:com.itextpdf:itextpdf:5.5.13.3
2.解决pdf中文字体问题:com.itextpdf:font-asian:7.2.3
3.pdf富文本拓展:com.itextpdf.tool:xmlworker:5.5.13 (好像没用到o
4.hutool工具类jar: cn.hutool.core.io:5.8.25

一、电子签章封面图的制作

注意:已经有封面图(电子公章图片)的朋友们可以略过;

参考视频:在WPS里如果制作电子印章_哔哩哔哩_bilibili

本人制作的效果图:

二、本地生成自测用电子证书

在后面的加签过程中,我们需要用到 .p12格式的证书文件,这里可以用本地自带的jdk生成测试用的。

网上有非常多的方式方法和博客可以参考,这里就不再赘述了。

三、绘制自定义样式的PDF文件并生成带有电子签章的PDF文件

我用的方法是与前端同学约定好入参字段及格式,做成简易版的PDF自定义样式。大家也可以用富文本直接做入参,但是有些样式可能不好调试,看大家的使用习惯吧。

Controller层代码

复制代码
 package com.xxxxx.user.controller.pdf.controller;

    
  
    
 import cn.hutool.core.date.DateUtil;
    
 import cn.hutool.core.io.FileUtil;
    
 import com.xxxxx.common.annotation.Anonymous;
    
 import com.xxxxx.common.response.BizResponseUtil;
    
 import com.xxxxx.user.base.BaseController;
    
 import com.xxxxx.user.controller.pdf.constants.Constant;
    
 import com.xxxxx.user.controller.pdf.pojo.param.MatchItem;
    
 import com.xxxxx.user.controller.pdf.pojo.param.PdfParam;
    
 import com.xxxxx.user.controller.pdf.pojo.param.SignatureInfo;
    
 import com.xxxxx.user.controller.pdf.util.PDFUtil;
    
 import com.tencent.ssv.techinfra.spring.boot.response.pojo.Response;
    
 import jakarta.validation.Valid;
    
 import java.io.File;
    
 import lombok.extern.slf4j.Slf4j;
    
 import org.springframework.web.bind.annotation.PostMapping;
    
 import org.springframework.web.bind.annotation.RequestBody;
    
 import org.springframework.web.bind.annotation.RequestMapping;
    
 import org.springframework.web.bind.annotation.RestController;
    
  
    
 /** * PDF制作 控制器
    
  */
    
 @Slf4j
    
 @RestController
    
 @RequestMapping("/pdfMake")
    
 public class PdfMakeController extends BaseController {
    
  
    
  
    
     /** * 制作pdf
    
      */
    
     @PostMapping("/createPdf")
    
     public Response<Void> createPdf(@Valid @RequestBody PdfParam param) {
    
     String tempFilePath = null;
    
     try {
    
         //1.生成临时pdf文件名(年月日时分秒微秒)
    
         String tempFileName = DateUtil.format(DateUtil.date(), "yyyyMMddHHmmssSSS") + "_temp" + Constant.PDF_SUFFIX;
    
         //2.临时文件路径:区分操作系统类型
    
         if (FileUtil.isWindows()) {
    
             tempFilePath = Constant.PDF_PATH_WINDOWS + "/" + tempFileName;
    
         } else {
    
             tempFilePath = Constant.PDF_PATH_LINUX + "/" + tempFileName;
    
         }
    
         //3.创建临时文件及其父目录,如果这个文件存在,直接返回这个文件
    
         File tempFile = FileUtil.touch(tempFilePath);
    
         //4.生成不带电子签章的PDF文件
    
         PDFUtil.createTempPDF(tempFile, param);
    
         // 5-根据关键字获取需要签章的位置
    
         MatchItem matchItem = PDFUtil.getKeyWordsByPath(tempFilePath, Constant.INSCRIBE_NAME);
    
         // 6-获取和封装签章所需信息
    
         SignatureInfo signatureInfo = PDFUtil.getSignatureInfo();
    
         // 7-加盖电子签章,数字签名,并将图片插入到pdf中
    
         PDFUtil.sign(tempFilePath, matchItem, signatureInfo);
    
         return BizResponseUtil.success();
    
     } catch (Exception e) {
    
         log.error("pdf制作失败:{}", e);
    
         return BizResponseUtil.fail("PdfMakeFailed", "pdf制作失败");
    
     } finally {
    
         try {
    
             // 8-删除临时pdf文件
    
             // 8.1-调用GC,强制删除
    
             //显式调用gc会造成性能黑洞,但是临时文件又没有存在的必要,仍需要删除,可以考虑做个定时器每日0点扫描文件夹删除
    
 //                System.gc();
    
             // 8.2-删除临时文件
    
             FileUtil.del(tempFilePath);
    
         } catch (Exception e) {
    
             log.error("删除临时文件失败:{}", e);
    
         }
    
     }
    
     }
    
  
    
 }
    
    
    
    

PDFUtil相关代码

复制代码
 package com.xxxxx.user.controller.pdf.util;

    
  
    
 import cn.hutool.core.date.DateUtil;
    
 import cn.hutool.core.io.FileUtil;
    
 import com.baomidou.mybatisplus.core.toolkit.StringUtils;
    
 import com.xxxxx.user.controller.pdf.constants.Constant;
    
 import com.xxxxx.user.controller.pdf.controller.PdfMakeController;
    
 import com.xxxxx.user.controller.pdf.helper.ContentEventHelper;
    
 import com.xxxxx.user.controller.pdf.listener.CustomRenderListener;
    
 import com.xxxxx.user.controller.pdf.pojo.param.ContentStyle;
    
 import com.xxxxx.user.controller.pdf.pojo.param.ItemTable;
    
 import com.xxxxx.user.controller.pdf.pojo.param.ItemTableRow;
    
 import com.xxxxx.user.controller.pdf.pojo.param.MatchItem;
    
 import com.xxxxx.user.controller.pdf.pojo.param.PDFColor;
    
 import com.xxxxx.user.controller.pdf.pojo.param.PDFFont;
    
 import com.xxxxx.user.controller.pdf.pojo.param.PdfParam;
    
 import com.xxxxx.user.controller.pdf.pojo.param.SignatureInfo;
    
 import com.itextpdf.text.Chunk;
    
 import com.itextpdf.text.Document;
    
 import com.itextpdf.text.DocumentException;
    
 import com.itextpdf.text.Element;
    
 import com.itextpdf.text.Image;
    
 import com.itextpdf.text.PageSize;
    
 import com.itextpdf.text.Paragraph;
    
 import com.itextpdf.text.Phrase;
    
 import com.itextpdf.text.Rectangle;
    
 import com.itextpdf.text.pdf.PdfPCell;
    
 import com.itextpdf.text.pdf.PdfPTable;
    
 import com.itextpdf.text.pdf.PdfReader;
    
 import com.itextpdf.text.pdf.PdfSignatureAppearance;
    
 import com.itextpdf.text.pdf.PdfStamper;
    
 import com.itextpdf.text.pdf.PdfWriter;
    
 import com.itextpdf.text.pdf.parser.PdfReaderContentParser;
    
 import com.itextpdf.text.pdf.security.BouncyCastleDigest;
    
 import com.itextpdf.text.pdf.security.DigestAlgorithms;
    
 import com.itextpdf.text.pdf.security.ExternalDigest;
    
 import com.itextpdf.text.pdf.security.ExternalSignature;
    
 import com.itextpdf.text.pdf.security.MakeSignature;
    
 import com.itextpdf.text.pdf.security.MakeSignature.CryptoStandard;
    
 import com.itextpdf.text.pdf.security.PrivateKeySignature;
    
 import java.io.File;
    
 import java.io.FileOutputStream;
    
 import java.io.IOException;
    
 import java.io.OutputStream;
    
 import java.math.BigDecimal;
    
 import java.net.URL;
    
 import java.nio.file.Files;
    
 import java.nio.file.Paths;
    
 import java.security.KeyStore;
    
 import java.security.PrivateKey;
    
 import java.security.cert.Certificate;
    
 import java.util.LinkedList;
    
 import java.util.List;
    
 import lombok.extern.slf4j.Slf4j;
    
 import org.springframework.core.io.ClassPathResource;
    
  
    
 @Slf4j
    
 public class PDFUtil {
    
  
    
     /** * 生成不带电子签章的PDF文件
    
      * * @param tempFile 临时文件
    
      * @param param 参数
    
      */
    
     public static void createTempPDF(File tempFile, PdfParam param) {
    
     //将tempFile转为输出流
    
     try (OutputStream tempOutputStream = new FileOutputStream(tempFile)) { // 创建输出流
    
         // 1-创建文本对象 Document
    
         Document document = new Document(PageSize.A4, Constant.DOCUMENT_MARGIN_LEFT, Constant.DOCUMENT_MARGIN_RIGHT,
    
                 Constant.DOCUMENT_MARGIN_TOP, Constant.DOCUMENT_MARGIN_BOTTOM);
    
         // 2-初始化 pdf输出对象 PdfWriter
    
         PdfWriter pdfWriter = PdfWriter.getInstance(document, tempOutputStream);
    
         // 2.1-设置背景图
    
         pdfWriter.setPageEvent(new ContentEventHelper());
    
         // 3-打开 Document
    
         document.open();
    
         // 4-往 Document 添加内容
    
         // 4.1-自定义的标题
    
         Chunk inviteTile1 = new Chunk(Constant.INVITE_TITLE_PREFIX, PDFFont.content1Font);
    
         Chunk inviteTile2 = new Chunk(param.getInviteTitle(), PDFFont.contentBlueFont);
    
         Chunk inviteTile3 = new Chunk(Constant.INVITE_TITLE_SUFFIX, PDFFont.content1Font);
    
         document.add(inviteTile1);
    
         document.add(inviteTile2);
    
         document.add(inviteTile3);
    
         // 4.2-自定义的内容
    
         String inviteContent = param.getInviteContent();
    
         // 4.2.1-设置自定义内容的样式
    
         LinkedList inviteLinkedList = PDFUtil.setParagraphStyle(inviteContent, param.getContentStyleList());
    
         // 4.2.2-添加自定义内容到文档
    
         Paragraph inviteContentParagraph;
    
         if (inviteLinkedList.isEmpty()) {
    
             inviteContentParagraph = new Paragraph(inviteContent, PDFFont.content1Font);
    
         } else {
    
             inviteContentParagraph = new Paragraph();
    
             for (Object o : inviteLinkedList) {
    
                 inviteContentParagraph.add((Element) o);
    
             }
    
         }
    
         inviteContentParagraph.setAlignment(Element.ALIGN_JUSTIFIED);// 对齐方式
    
         inviteContentParagraph.setFirstLineIndent(Constant.INVITE_TITLE_PARAGRAPH_FIRST_LINE_INDENT);// 首行缩进
    
         inviteContentParagraph.setLeading(Constant.PARAGRAPH_LEADING);// 行间距
    
         inviteContentParagraph.setSpacingBefore(Constant.INVITE_TITLE_PARAGRAPH_SPACING_BEFORE);// 设置上空白
    
         inviteContentParagraph.setSpacingAfter(Constant.INVITE_TITLE_PARAGRAPH_SPACING_AFTER);// 设置段落下空白
    
         document.add(inviteContentParagraph);
    
         // 4.3-自定义事项
    
         for (int i = 0; i < param.getInviteItemList().size(); i++) {
    
             // 4.3.1-设置自定义事项内容的样式
    
             String item = param.getInviteItemList().get(i).getItemContent();
    
             LinkedList itemLinkedList = PDFUtil.setParagraphStyle(item,
    
                     param.getInviteItemList().get(i).getContentStyleList());
    
             // 4.3.2-添加自定义事项内容到文档
    
             Paragraph inviteItem;
    
             if (itemLinkedList.isEmpty()) {
    
                 inviteItem = new Paragraph(item, PDFFont.content1Font);
    
             } else {
    
                 inviteItem = new Paragraph();
    
                 for (Object o : itemLinkedList) {
    
                     inviteItem.add((Element) o);
    
                 }
    
             }
    
             inviteItem.setAlignment(Element.ALIGN_JUSTIFIED);// 对齐方式
    
             inviteItem.setLeading(Constant.PARAGRAPH_LEADING);// 行间距
    
             //如果表格不存在,则设置段落下空白
    
             ItemTable itemTable = param.getInviteItemList().get(i).getItemTable();
    
             if (itemTable == null) {
    
                 if (i < param.getInviteItemList().size() - 1) {
    
                     inviteItem.setSpacingAfter(Constant.INVITE_ITEM_PARAGRAPH_SPACING_AFTER);// 设置段落下空白
    
                 } else {
    
                     //最后一个段落的下空白设置多一点
    
                     inviteItem.setSpacingAfter(Constant.INVITE_ITEM_PARAGRAPH_MORE_SPACING_AFTER);// 设置段落下空白
    
                 }
    
             }
    
             document.add(inviteItem);
    
             // 4.3.2-自定义事项表格
    
             if (itemTable != null) {
    
                 List<ItemTableRow> rowList = itemTable.getRowList();
    
                 //  获取表格列数
    
                 int columnCount = rowList.get(0).getColumnList().size();
    
                 //计算表格可以使用的宽度:A4纸的宽度减去左右边距各90
    
                 float tableWidth = new BigDecimal(PageSize.A4.getWidth())
    
                         .subtract(new BigDecimal(Constant.DOCUMENT_MARGIN_LEFT))
    
                         .subtract(new BigDecimal(Constant.DOCUMENT_MARGIN_RIGHT))
    
                         .setScale(2, BigDecimal.ROUND_HALF_UP).floatValue();
    
                 // 计算表格的每列所占宽度
    
                 float[] widths = new float[columnCount];
    
                 for (int j = 0; j < columnCount; j++) {
    
                     widths[j] = new BigDecimal(tableWidth).divide(new BigDecimal(columnCount), 2,
    
                             BigDecimal.ROUND_HALF_UP).floatValue();
    
                 }
    
                 //设置表格属性
    
                 PdfPTable table = new PdfPTable(columnCount);
    
                 table.setHorizontalAlignment(Element.ALIGN_LEFT);
    
                 table.setSpacingBefore(Constant.INVITE_ITEM_PARAGRAPH_SPACING_BEFORE);
    
                 if (i < param.getInviteItemList().size() - 1) {
    
                     table.setSpacingAfter(Constant.INVITE_ITEM_PARAGRAPH_SPACING_AFTER);
    
                 } else {
    
                     //最后一个段落的下空白设置多一点
    
                     table.setSpacingAfter(Constant.INVITE_ITEM_PARAGRAPH_MORE_SPACING_AFTER);
    
                 }
    
                 table.setWidths(widths);
    
                 table.setWidthPercentage(Constant.INVITE_ITEM_TABLE_WIDTH_PERCENTAGE);
    
                 //设置单元格属性
    
                 PdfPCell pdfPContentCell = new PdfPCell();
    
                 //单元格水平对齐方式:居中对齐
    
                 pdfPContentCell.setHorizontalAlignment(Element.ALIGN_CENTER);
    
                 //单元格垂直对齐方式:居中对齐
    
                 pdfPContentCell.setVerticalAlignment(Element.ALIGN_CENTER);
    
                 //设置单元格内容
    
                 for (ItemTableRow itemTableRow : rowList) {
    
                     for (int z = 0; z < itemTableRow.getColumnList().size(); z++) {
    
                         Phrase phrase = new Phrase(itemTableRow.getColumnList().get(z), PDFFont.content1Font);
    
                         pdfPContentCell.setPhrase(phrase);
    
                         PdfPCell cell = new PdfPCell(pdfPContentCell);
    
                         cell.setUseAscender(true);
    
                         //水平对齐
    
                         cell.setHorizontalAlignment(Element.ALIGN_LEFT);
    
                         //居中
    
                         cell.setVerticalAlignment(Element.ALIGN_MIDDLE);
    
                         //行距:设置前导固定和变量。结果前导将是:fixedLeading+multipliedLeading*maxFontSize,其中maxFontSize是该行中最大字体的大小。
    
                         cell.setLeading(Constant.INVITE_ITEM_TABLE_CELL_FIXED_LEADING,
    
                                 Constant.INVITE_ITEM_TABLE_CELL_MULTIPLIED_LEADING);
    
                         //边框颜色
    
                         cell.setBorderColor(PDFColor.TABLE_BORDER);
    
                         //单元格内边距
    
                         cell.setPadding(Constant.INVITE_ITEM_TABLE_CELL_PADDING);
    
                         //将单元格加入表格
    
                         table.addCell(cell);
    
                     }
    
                 }
    
                 document.add(table);
    
             }
    
         }
    
         // 4.4-落款名称及落款日期
    
         String inscribeDate = DateUtil.format(DateUtil.date(), "yyyy年MM月");
    
         PDFElement.creatBottomTable(document, " ", " ", Constant.INSCRIBE_NAME);
    
         PDFElement.creatBottomTable(document, " ", " ", inscribeDate);
    
         // 4.4.1-暂时关闭文档,改为PdfReader读取pdf文档内容,进而根据关键字找到需要签章的位置
    
         document.close();
    
     } catch (Exception e) {
    
         log.error("pdf制作失败:{}", e);
    
         throw new RuntimeException("pdf制作失败");
    
     }
    
     }
    
  
    
     /** * 用于供外部类调用获取关键字所在PDF文件坐标
    
      * * @param tempFilePath 临时文件路径
    
      * @param keyWords 关键字
    
      */
    
     public static MatchItem getKeyWordsByPath(String tempFilePath, String keyWords) {
    
     MatchItem matchItem = null;
    
     PdfReader pdfReader = null;
    
     try {
    
         pdfReader = new PdfReader(tempFilePath);
    
         matchItem = getKeyWords(pdfReader, keyWords);
    
     } catch (IOException e) {
    
         log.error("获取关键字坐标失败:{}", e);
    
     } finally {
    
         try {
    
             if (pdfReader != null) {
    
                 pdfReader.close();
    
             }
    
         } catch (Exception e1) {
    
             log.error("关闭pdf读取流失败:{}", e1);
    
         }
    
     }
    
     return matchItem;
    
     }
    
  
    
     /** * 获取关键字所在PDF坐标
    
      * * @param pdfReader pdfReader对象
    
      * @param keyWords 关键字
    
      */
    
     private static MatchItem getKeyWords(PdfReader pdfReader, String keyWords) {
    
     try {
    
         //获取PDF文档的总页数
    
         int pageNum = pdfReader.getNumberOfPages();
    
         //创建PdfReaderContentParser对象,用来遍历PDF文档的页面内容,提取文本、图像、字体信息等
    
         PdfReaderContentParser pdfReaderContentParser = new PdfReaderContentParser(pdfReader);
    
         //这里实现了RenderListener接口,用于监听PDF文档的文本对象,块对象和段落对象等等;
    
         CustomRenderListener renderListener = new CustomRenderListener();
    
         renderListener.setKeyWord(keyWords);
    
         StringBuilder allText;
    
         for (int page = 1; page <= pageNum; page++) {
    
             renderListener.setPage(page);
    
             pdfReaderContentParser.processContent(page, renderListener);
    
             List<MatchItem> matchItems = renderListener.getMatchItems();
    
             if (matchItems != null && matchItems.size() > 0) {
    
                 //完全匹配:取最后一个
    
                 return matchItems.get(matchItems.size() - 1);
    
             }
    
             List<MatchItem> allItems = renderListener.getAllItems();
    
             allText = new StringBuilder();
    
             for (MatchItem item : allItems) {
    
                 allText.append(item.getContent());
    
                 //关键字存在连续多个块中
    
                 if (allText.indexOf(keyWords) != -1) {
    
                     return item;
    
                 }
    
             }
    
         }
    
     } catch (IOException e) {
    
         log.error("获取关键字坐标失败:{}", e);
    
         throw new RuntimeException("获取关键字坐标失败");
    
     }
    
     return null;
    
     }
    
  
    
     /** * 获取签章信息
    
      * * @return
    
      */
    
     public static SignatureInfo getSignatureInfo() {
    
     try {
    
         // 封装签章信息
    
         // 将证书文件放入指定路径,并读取keystore文件,获得私钥和证书链
    
         String privateKeyPath;
    
         if (FileUtil.isWindows()) {
    
             //本地测试用
    
             String path = System.getProperty("user.dir");
    
             privateKeyPath = path + "\ src\ main\ resources\ " + Constant.CERTIFICATE_PATH;
    
         } else {
    
             ClassPathResource classPathResource = new ClassPathResource(Constant.CERTIFICATE_PATH);
    
             privateKeyPath = classPathResource.getPath();
    
         }
    
         /** * 调用KeyStore类的getInstance方法,请求一个类型为PKCS#12的KeyStore实例。
    
          * 这个实例之后可以用于加载、存储和管理密钥和证书
    
          */
    
         KeyStore keyStore = KeyStore.getInstance(Constant.PKCS12);
    
         try {
    
             keyStore.load(Files.newInputStream(Paths.get(privateKeyPath)), Constant.PASSWORD.toCharArray());
    
         } catch (Exception e) {
    
             log.error("读取证书文件失败:{}", e);
    
             throw new RuntimeException(e.getMessage());
    
         }
    
         //获取密钥别名(要保证别名有且只有1个,否则需要做特殊判断)
    
         String alias = keyStore.aliases().nextElement();
    
         // 通过别名和密码获取私钥对象
    
         PrivateKey privateKey = (PrivateKey) keyStore.getKey(alias, Constant.PASSWORD.toCharArray());
    
         // 得到证书链
    
         Certificate[] chain = keyStore.getCertificateChain(alias);
    
         //获取电子签章显示的图片
    
         URL signImgUrl = PdfMakeController.class.getResource(Constant.SIGN_IMAGE_PATH);
    
         SignatureInfo signatureInfo = new SignatureInfo();
    
         //签名原因(非必填)
    
         signatureInfo.setReason(Constant.SIGN_REASON);
    
         signatureInfo.setPrivateKey(privateKey);
    
         signatureInfo.setChain(chain);
    
         signatureInfo.setCertificationLevel(PdfSignatureAppearance.NOT_CERTIFIED);
    
         signatureInfo.setDigestAlgorithm(DigestAlgorithms.SHA256);
    
         signatureInfo.setRenderingMode(PdfSignatureAppearance.RenderingMode.GRAPHIC);
    
         signatureInfo.setImagePath(signImgUrl);
    
         return signatureInfo;
    
     } catch (Exception e) {
    
         log.error("获取签章信息失败:{}", e);
    
         throw new RuntimeException("获取签章信息失败");
    
     }
    
     }
    
  
    
  
    
     /** * 单多次签章通用
    
      * * @param pdfFilePath PDF临时文件路径(未盖章)
    
      * @param matchItem 关键字所在位置
    
      * @param signatureInfo 电子签章实体
    
      */
    
     public static void sign(String pdfFilePath, MatchItem matchItem, SignatureInfo signatureInfo) {
    
     FileOutputStream fileOutputStream = null;
    
     PdfReader reader = null;
    
     PdfStamper stamper = null;
    
     try {
    
         String newPdfPath = getNewFilePath(pdfFilePath);
    
         // 读取pdf文件
    
         reader = new PdfReader(pdfFilePath);
    
         //输出流
    
         fileOutputStream = new FileOutputStream(newPdfPath);
    
         // 创建签章工具PdfStamper ,最后一个boolean参数是否允许被追加签名
    
         // false的话,pdf文件只允许被签名一次,多次签名,最后一次有效
    
         // true的话,pdf可以被追加签名,验签工具可以识别出每次签名之后文档是否被修改
    
         stamper = PdfStamper.createSignature(reader, fileOutputStream, '\0', null, false);
    
         // 获取数字签章属性对象
    
         PdfSignatureAppearance appearance = stamper.getSignatureAppearance();
    
         appearance.setReason(signatureInfo.getReason());
    
         appearance.setLocation(signatureInfo.getLocation());
    
         // 设置签名的位置,页码,签名域名称,多次追加签名的时候,签名预名称不能一样 图片大小受表单域大小影响(过小导致压缩)
    
         // 签名的位置,是图章相对于pdf页面的位置坐标,原点为pdf页面左下角
    
         // 四个参数的分别是,图章左下角x,图章左下角y,图章右上角x,图章右上角y(签章的位置和大小相当难调,非必要不建议更改)
    
         Rectangle rectangle = new Rectangle(matchItem.getX() - 13, matchItem.getY() - 65, matchItem.getX() + 110,
    
                 matchItem.getY() + 58);
    
         appearance.setVisibleSignature(rectangle, matchItem.getPageNum(), "sign");
    
         // 读取图章图片
    
         Image image = Image.getInstance(signatureInfo.getImagePath());
    
         appearance.setSignatureGraphic(image);
    
         /** * 设置认证等级,共4种,分别为:
    
          *  NOT_CERTIFIED、CERTIFIED_NO_CHANGES_ALLOWED、
    
          *  CERTIFIED_FORM_FILLING 和 CERTIFIED_FORM_FILLING_AND_ANNOTATIONS
    
          * 需要用哪一种根据业务流程自行选择
    
          */
    
         appearance.setCertificationLevel(signatureInfo.getCertificationLevel());
    
         /** * 印章的渲染方式,同样有4种(这里设置的是只显示图章):
    
          *  DESCRIPTION、NAME_AND_DESCRIPTION,
    
          *  GRAPHIC_AND_DESCRIPTION,GRAPHIC;
    
          * 这里选择只显示印章
    
          */
    
         appearance.setRenderingMode(signatureInfo.getRenderingMode());
    
         /** * 摘要算法
    
          * 算法主要为:RSA、DSA、ECDSA
    
          * 这里的itext提供了2个用于签名的接口,可以自己实现
    
          */
    
         ExternalDigest digest = new BouncyCastleDigest();
    
         /** * 签名算法
    
          * 参数依次为:证书秘钥、摘要算法名称,例如MD5 | SHA-1 | SHA-2.... 以及 提供者
    
          */
    
         ExternalSignature signature = new PrivateKeySignature(signatureInfo.getPrivateKey(),
    
                 signatureInfo.getDigestAlgorithm(), null);
    
         /** * 最重要的来了,调用itext签名方法完成pdf签章
    
          * 数字签名格式,CMS,CADE
    
          * 注意(别踩坑):wps使用的是CMS格式,如果使用CADES格式则无法被wps识别
    
          */
    
         MakeSignature.signDetached(appearance, digest, signature, signatureInfo.getChain(),
    
                 null, null, null, 0, CryptoStandard.CMS);
    
     } catch (Exception e) {
    
         log.error("为PDF添加电子签章失败:{}", e);
    
     } finally {
    
         try {
    
             if (reader != null) {
    
                 reader.close();
    
             }
    
             if (stamper != null) {
    
                 stamper.close();
    
             }
    
             if (fileOutputStream != null) {
    
                 fileOutputStream.close();
    
             }
    
         } catch (IOException e) {
    
             e.printStackTrace();
    
             log.error("pdf签章失败:io流关闭失败:{}", e);
    
         } catch (DocumentException e) {
    
             log.error("pdf签章失败:stamper关闭失败:{}", e);
    
         }
    
     }
    
     }
    
  
    
     private static String getNewFilePath(String oldPdfFilePath) {
    
     //新pdf文件的地址
    
     String newPdfPath =
    
             oldPdfFilePath.substring(0, oldPdfFilePath.lastIndexOf(".")) + Constant.PDF_FILE_NAME_FINISH
    
                     + oldPdfFilePath.substring(oldPdfFilePath.lastIndexOf("."));
    
     return newPdfPath.replace("_temp", "");
    
     }
    
  
    
  
    
     /** * 设置段落样式
    
      * * @param content 内容
    
      * @param contentStyleList 内容样式
    
      * @return
    
      */
    
     public static LinkedList setParagraphStyle(String content, List<ContentStyle> contentStyleList) {
    
     //邀请内容按照样式拆分后的语句对象列表
    
     LinkedList linkedList = new LinkedList();
    
     // 邀请内容样式如果为空直接返回
    
     if (contentStyleList == null || contentStyleList.isEmpty()) {
    
         return linkedList;
    
     }
    
     //需要拆分的全部内容
    
     String substr = content;
    
     for (int i = 0; i < contentStyleList.size(); i++) {
    
         ContentStyle style = contentStyleList.get(i);
    
         // 找到样式文字在内容中的位置
    
         int index = substr.indexOf(style.getContent());
    
         String strBefore = substr.substring(0, index);
    
         String strCurrent = style.getContent();
    
         substr = substr.substring(index + style.getContent().length());
    
         //将拆分后的文字添加到短语对象中
    
         if (StringUtils.isNotBlank(strBefore)) {
    
             //正常字体
    
             Phrase phraseBefore = new Phrase(strBefore, PDFFont.content1Font);
    
             linkedList.add(phraseBefore);
    
         }
    
         //设置带有样式的文字
    
         //超链接不为空,则设置超链接
    
         Chunk chunkCurrent = null;
    
         if (StringUtils.isNotBlank(style.getLinkUrl())) {
    
             chunkCurrent = new Chunk(strCurrent, PDFFont.anchorFont);
    
             chunkCurrent.setAnchor(style.getLinkUrl());
    
             chunkCurrent.setUnderline(0, -3f);
    
         } else {
    
             if (style.getStrikingColor() != null && style.getStrikingColor()
    
                     && style.getBold() != null && style.getBold()) {
    
                 chunkCurrent = new Chunk(strCurrent, PDFFont.contentBoldBlueFont);
    
             } else if (style.getStrikingColor() != null && style.getStrikingColor()) {
    
                 chunkCurrent = new Chunk(strCurrent, PDFFont.contentBlueFont);
    
             } else if (style.getBold() != null && style.getBold()) {
    
                 chunkCurrent = new Chunk(strCurrent, PDFFont.contentBoldFont);
    
             }
    
         }
    
         linkedList.add(chunkCurrent);
    
         //最后一个样式
    
         if (i == contentStyleList.size() - 1) {
    
             if (StringUtils.isNotBlank(substr)) {
    
                 //正常字体
    
                 Phrase phraseBefore = new Phrase(substr, PDFFont.content1Font);
    
                 linkedList.add(phraseBefore);
    
             }
    
         }
    
     }
    
     return linkedList;
    
     }
    
  
    
 }
    
    
    
    

常量类:Constant.java

复制代码
     /** * 0 整数
    
      */
    
     public static final Integer ZERO_INTEGER = 0;    
    
  
    
     /** * windows环境生成的pdf文件路径
    
      */
    
     public static final String PDF_PATH_WINDOWS = "D:/PdfMake";
    
  
    
     /** * linux环境生成的pdf文件路径
    
      */
    
     public static final String PDF_PATH_LINUX = "/PdfMake";
    
  
    
     /** * PDF文档(A4纸大小)的页边距
    
      */
    
     public static final Float DOCUMENT_MARGIN_LEFT = 90.0F;
    
     public static final Float DOCUMENT_MARGIN_RIGHT = 90.0F;
    
     public static final Float DOCUMENT_MARGIN_TOP = 128.0F;
    
     public static final Float DOCUMENT_MARGIN_BOTTOM = 76.0F;
    
  
    
     /** * PDF文档背景图片路径
    
      */
    
     public static final String BACKGROUND_LOGO_PATH = "/pdf/img/logo-background.jpg";
    
  
    
     /** * pdf文件后缀
    
      */
    
     public static final String PDF_SUFFIX = ".pdf";
    
  
    
     /** * PDF文档末尾的落款名称
    
      */
    
     public static final String INSCRIBE_NAME = "xxxx公司";
    
  
    
     /** * 自定义标题前缀
    
      */
    
     public static final String INVITE_TITLE_PREFIX = "尊敬的 ";
    
  
    
     /** * 自定义标题后缀
    
      */
    
     public static final String INVITE_TITLE_SUFFIX = ":";
    
  
    
     /** * 自定义标题-段落-首行缩进
    
      */
    
     public static final float INVITE_TITLE_PARAGRAPH_FIRST_LINE_INDENT = 16f;
    
  
    
     /** * 全文-段落-行间距
    
      */
    
     public static final float PARAGRAPH_LEADING = 20f;
    
     
    
     /** * 自定义标题-段落-上空白
    
      */
    
     public static final float INVITE_TITLE_PARAGRAPH_SPACING_BEFORE = 20f;
    
  
    
     /** * 自定义标题-段落-下空白
    
      */
    
     public static final float INVITE_TITLE_PARAGRAPH_SPACING_AFTER = 10f;
    
  
    
     /** * 自定义事项-段落-下空白
    
      */
    
     public static final float INVITE_ITEM_PARAGRAPH_SPACING_AFTER = 20f;
    
  
    
     /** * 自定义事项-最后一个段落-下空白
    
      */
    
     public static final float INVITE_ITEM_PARAGRAPH_MORE_SPACING_AFTER = 50f;
    
  
    
     /** * 自定义事项-段落-上空白
    
      */
    
     public static final float INVITE_ITEM_PARAGRAPH_SPACING_BEFORE = 10f;
    
  
    
     /** * 自定义事项-表格-宽度:表格的宽度为相对于页面宽度的百分比
    
      */
    
     public static final float INVITE_ITEM_TABLE_WIDTH_PERCENTAGE = 96f;    
    
     
    
     /** * 自定义事项-表格-单元格:行距-固定量
    
      */
    
     public static final float INVITE_ITEM_TABLE_CELL_FIXED_LEADING = 1.3f;    
    
  
    
     /** * 自定义事项-表格-单元格:行距-变量
    
      */
    
     public static final float INVITE_ITEM_TABLE_CELL_MULTIPLIED_LEADING = 1.3f;
    
  
    
     /** * 自定义事项-表格-单元格:内边距
    
      */
    
     public static final float INVITE_ITEM_TABLE_CELL_PADDING = 5f;
    
  
    
     /** * 电子签章功能: 电子证书文件路径
    
      */
    
     public static final String CERTIFICATE_PATH = "pdf/certificate/test.p12";   
    
  
    
     /** * 电子签章功能: 证书文件格式p12
    
      */
    
     public static final String PKCS12 = "PKCS12";
    
  
    
     /** * 电子签章功能: keystory密码(自己设置个复杂性高的,这里作演示用)
    
      */
    
     public static final String PASSWORD = "123456"; 
    
  
    
     /** * 电子签章功能: 图章图片地址
    
      */
    
     public static final String SIGN_IMAGE_PATH = "/pdf/img/signature.png";
    
  
    
     /** * 电子签章功能: 签章的原因(非必填)
    
      */
    
     public static final String SIGN_REASON = "电子证书,电子签章";     
    
  
    
    
    
    

字体样式常量类:PDFFont.java

复制代码
 package com.xxxxx.user.controller.pdf.pojo.param;

    
  
    
 import com.itextpdf.text.BaseColor;
    
 import com.itextpdf.text.Font;
    
 import com.itextpdf.text.pdf.BaseFont;
    
 import org.springframework.core.io.ClassPathResource;
    
  
    
 public class PDFFont {
    
  
    
     public static Font content1Font; // 正文内容字体 11号 黑色
    
     public static Font contentBlueFont; // 正文内容字体 11号 蓝色
    
     public static Font anchorFont; // 正文超链接内容字体 11号 蓝色
    
     public static Font contentBoldBlueFont; // 正文内容加粗字体 11号 蓝色
    
     public static Font contentBoldFont; // 正文内容加粗字体 11号 黑色
    
     
    
     static {
    
     try {
    
         ClassPathResource classPathResource = new ClassPathResource("pdf/fonts/xxxxx.ttf");
    
         String fontFile = classPathResource.getPath();
    
         BaseFont chineseFont = BaseFont.createFont(fontFile, BaseFont.IDENTITY_H, BaseFont.NOT_EMBEDDED);
    
  
    
         content1Font = new Font(chineseFont, 11, Font.NORMAL, PDFColor.BLACK);
    
         contentBlueFont = new Font(chineseFont, 11, Font.NORMAL, PDFColor.CONTENT_BLUE);
    
         anchorFont = new Font(chineseFont, 11, Font.NORMAL, PDFColor.ANCHOR_BLUE);
    
         contentBoldBlueFont = new Font(chineseFont, 11, Font.BOLD, PDFColor.CONTENT_BLUE);
    
         contentBoldFont = new Font(chineseFont, 11, Font.BOLD, PDFColor.BLACK);
    
     } catch (Exception e) {
    
         e.printStackTrace();
    
     }
    
     }    
    
  
    
 }
    
    
    
    

颜色常量类:PDFColor.java

复制代码
 package com.xxxxx.user.controller.pdf.pojo.param;

    
  
    
 import com.itextpdf.text.BaseColor;
    
  
    
 public class PDFColor {
    
     public static final BaseColor BLACK = new BaseColor(26, 26, 26);
    
     public static final BaseColor CONTENT_BLUE = new BaseColor(0, 111, 192);
    
     public static final BaseColor ANCHOR_BLUE = new BaseColor(0, 0, 255);
    
     public static final BaseColor TABLE_BORDER = new BaseColor(202, 205, 209);
    
 }
    
    
    
    

pdf内容事件监听

复制代码
 package com.xxxxx.user.controller.pdf.helper;

    
  
    
 import com.xxxxx.user.controller.pdf.constants.Constant;
    
 import com.itextpdf.text.Document;
    
 import com.itextpdf.text.Image;
    
 import com.itextpdf.text.PageSize;
    
 import com.itextpdf.text.pdf.PdfContentByte;
    
 import com.itextpdf.text.pdf.PdfPageEventHelper;
    
 import com.itextpdf.text.pdf.PdfWriter;
    
 import java.net.URL;
    
 import lombok.SneakyThrows;
    
 import lombok.extern.slf4j.Slf4j;
    
  
    
 /** * Description:
    
  * pdf内容事件监听
    
  * 用户处理页码,背景图等
    
  * * @author ggp
    
  * @date 2023/5/24 14:12
    
  */
    
 @Slf4j
    
 public class ContentEventHelper extends PdfPageEventHelper {
    
  
    
     private int page;
    
  
    
     private URL imgUrl = null;
    
  
    
     @Override
    
     @SneakyThrows
    
     public void onStartPage(PdfWriter writer, Document document) {
    
     page++;
    
     }
    
  
    
     @Override
    
     public void onEndPage(PdfWriter writer, Document document) {
    
     try {
    
         if (imgUrl == null) {
    
             imgUrl = this.getClass().getResource(Constant.BACKGROUND_LOGO_PATH);
    
         }
    
         //将图片添加到pdf页面内容的下方,充当背景图
    
         PdfContentByte contentByte = writer.getDirectContentUnder();
    
         Image image = Image.getInstance(imgUrl);
    
         // 调整图片大小适应页面
    
         image.scaleToFit(PageSize.A4.getWidth(), PageSize.A4.getHeight());
    
         //确定图片的起始位置
    
         image.setAbsolutePosition(Constant.ZERO_INTEGER, Constant.ZERO_INTEGER);
    
         //缩放图片大小与pdf页面大小一致
    
         image.scaleAbsolute(writer.getPageSize());
    
         contentByte.addImage(image);
    
     } catch (Exception e) {
    
         log.info("pdf插入背景图异常:{}", e.getMessage());
    
         //	e.printStackTrace();
    
     }
    
     }
    
 }
    
    
    
    

PDF内容节点工具类:PDFElement

用表格的形式制作尾页落款人及落款日期

复制代码
 public class PDFElement {

    
     public static void creatBottomTable(Document document, String key, String value, String orgKey)
    
         throws DocumentException {
    
     float[] widths = new float[]{24f, 25f, 33f};
    
     PdfPTable table = new PdfPTable(widths.length);
    
     table.setHorizontalAlignment(Element.ALIGN_CENTER);
    
     table.setWidthPercentage(89f);
    
     table.setTotalWidth(widths);
    
     table.setSpacingBefore(8f);
    
  
    
     Paragraph ph = new Paragraph(key, PDFFont.fieldValueFont);
    
     ph.setLeading(1f, 1f);
    
     PdfPCell cell = new PdfPCell(ph);
    
     cell.setBorder(Rectangle.NO_BORDER);
    
     cell.setUseAscender(true);
    
     cell.setHorizontalAlignment(Element.ALIGN_RIGHT);
    
     cell.setVerticalAlignment(Element.ALIGN_MIDDLE);
    
     cell.setLeading(1f, 1f);
    
     table.addCell(cell);
    
  
    
     Paragraph ph1 = new Paragraph(value, PDFFont.fieldValueFont);
    
     ph1.setLeading(1f, 1f);
    
     PdfPCell cell1 = new PdfPCell(ph1);
    
     cell1.setBorder(Rectangle.NO_BORDER);
    
     cell1.setUseAscender(true);
    
     cell1.setHorizontalAlignment(Element.ALIGN_CENTER);
    
     cell1.setVerticalAlignment(Element.ALIGN_CENTER);
    
     cell1.setLeading(1f, 1f);
    
     table.addCell(cell1);
    
  
    
     Paragraph ph2 = new Paragraph(orgKey, PDFFont.fieldValueFont);
    
     ph2.setLeading(1f, 1f);
    
     PdfPCell cell2 = new PdfPCell(ph2);
    
     cell2.setBorder(Rectangle.NO_BORDER);
    
     cell2.setUseAscender(true);
    
     cell2.setHorizontalAlignment(Element.ALIGN_CENTER); // 水平对齐
    
     cell2.setVerticalAlignment(Element.ALIGN_CENTER); // 垂直对齐
    
     cell2.setLeading(1f, 1f); // 行间距
    
     table.addCell(cell2);
    
     document.add(table);
    
     }
    
 }
    
    
    
    

CustomRenderListener.java

复制代码
 package com.xxxxx.user.controller.pdf.listener;

    
  
    
 import com.xxxxx.user.controller.pdf.pojo.param.MatchItem;
    
 import com.itextpdf.awt.geom.Rectangle2D;
    
 import com.itextpdf.text.pdf.parser.ImageRenderInfo;
    
 import com.itextpdf.text.pdf.parser.RenderListener;
    
 import com.itextpdf.text.pdf.parser.TextRenderInfo;
    
 import java.util.ArrayList;
    
 import java.util.List;
    
 import lombok.Data;
    
  
    
 @Data
    
 public class CustomRenderListener implements RenderListener {
    
  
    
     /** * 定位坐标的关键字
    
      */
    
     private String keyWord;
    
     /** * 关键字所在的页数
    
      */
    
     private int page;
    
     //所有匹配的项
    
     private List<MatchItem> matchItems = new ArrayList<>();
    
     //所有项
    
     private List<MatchItem> allItems = new ArrayList<>();
    
  
    
     @Override
    
     public void beginTextBlock() {
    
  
    
     }
    
  
    
     @Override
    
     public void renderText(TextRenderInfo textRenderInfo) {
    
     //获取文本内容
    
     String text = textRenderInfo.getText();
    
     Rectangle2D.Float boundingRectange = textRenderInfo.getBaseline().getBoundingRectange();
    
     MatchItem matchItem = new MatchItem();
    
     matchItem.setContent(text);
    
     matchItem.setPageNum(page);
    
     matchItem.setX(boundingRectange.x);
    
     matchItem.setY(boundingRectange.y);
    
     if (null != text && !" ".equals(text)) {
    
         if (text.equalsIgnoreCase(keyWord)) {
    
             matchItems.add(matchItem);
    
         }
    
     }
    
     allItems.add(matchItem);
    
     }
    
  
    
     @Override
    
     public void endTextBlock() {
    
  
    
     }
    
  
    
     @Override
    
     public void renderImage(ImageRenderInfo imageRenderInfo) {
    
  
    
     }
    
 }
    
    
    
    

制作Pdf内容的样式请求参数

复制代码
 package com.xxxxx.user.controller.pdf.pojo.param;

    
  
    
 import com.fasterxml.jackson.databind.PropertyNamingStrategy;
    
 import com.fasterxml.jackson.databind.annotation.JsonNaming;
    
 import io.swagger.v3.oas.annotations.media.Schema;
    
 import java.io.Serializable;
    
 import lombok.Data;
    
 import lombok.ToString;
    
  
    
 @Schema(name = "ContentStyle", description = "制作Pdf内容的样式请求参数")
    
 @Data
    
 @ToString
    
 @JsonNaming(PropertyNamingStrategy.UpperCamelCaseStrategy.class)
    
 public class ContentStyle implements Serializable {
    
  
    
     private static final long serialVersionUID = 1L;
    
  
    
     @Schema(description = "带有样式的内容文字")
    
     private String content;
    
  
    
     @Schema(description = "超链接地址")
    
     private String linkUrl;
    
  
    
     @Schema(description = "是否加粗")
    
     private Boolean bold;
    
  
    
     @Schema(description = "是否醒目颜色")
    
     private Boolean strikingColor;
    
  
    
 }
    
    
    
    

前端入参

复制代码
 {

    
     "InviteTitle": "java技术开发者",
    
     "InviteContent": "“Java实现自动生成PDF+电子签章”讲述的是如何通过java语言,生成带有电子签章和自定义样式的PDF文件。欢迎大家共同探讨,也希望大家能够积极指出博主未发现和可以优化的问题及内容。2024 年度即将迎来尾声,新的一年即将到来。2024年的圣诞晚会将于 12月25日在全球范围内举行,现开放系统进行参与报名(报名链接:报名入口)。相关事项说明如下:",
    
     "ContentStyleList": [
    
     {
    
         "Content": "12月25日在全球范围内",
    
         "LinkUrl": "",
    
         "Bold": true,
    
         "StrikingColor": false
    
     },
    
     {
    
         "Content": "报名入口",
    
         "LinkUrl": "https://www.baidu.com/",
    
         "Bold": false,
    
         "StrikingColor": false
    
     }
    
     ],
    
     "InviteItemList": [
    
     {
    
         "ItemContent": "1. 会议日程初步安排",
    
         "ItemTable": {
    
             "RowList": [
    
                 {
    
                     "ColumnList": [
    
                         "时间",
    
                         "12月22日",
    
                         "12月23日",
    
                         "12月24日",
    
                         "12月25日"
    
                     ]
    
                 },
    
                 {
    
                     "ColumnList": [
    
                         "上午",
    
                         "报到",
    
                         "交友聊天",
    
                         "交友聊天",
    
                         "交友聊天"
    
                     ]
    
                 },
    
                 {
    
                     "ColumnList": [
    
                         "下午",
    
                         "全体会议",
    
                         "交友聊天/爱心排队",
    
                         "交友聊天/爱心排队",
    
                         "交友聊天/爱心排队"
    
                     ]
    
                 },
    
                 {
    
                     "ColumnList": [
    
                         "晚上",
    
                         "欢迎晚宴",
    
                         "历史回顾",
    
                         "激情演讲",
    
                         "离会"
    
                     ]
    
                 }
    
             ]
    
         }
    
     },
    
     {
    
         "ItemContent": "2. 请您根据系统要求,提交报名所需相关信息。本次活动将根据报名情况,在整个活动安排4个场景(在下轮通知中由全体参会人员投票选出),并根据活动领域设立 “AAAA”“BBBB”两个特殊奖项。其中,每人表演时间15分钟,互动交流时间15分钟。另外,您如果没有演出的计划,欢迎积极报名担任本次活动主持人。"
    
     },
    
     {
    
         "ItemContent": "3. 晚会以表演歌舞为主,来源于每个系团总支精心筛选的节目,以及每个系团总支内部的节目。其中穿插些有意义的游戏和娱乐性表演,以及一些群体舞蹈。为了响应我校有关丰富学生娱乐生活,培养学生积极向上的情操,展现大学生的青春与活力,加强团总支的了解和沟通。此次活动作为一种交流与沟通的方式,增进情感交流,培养人际交往能力。"
    
     },
    
     {
    
         "ItemContent": "4. 活动之前五系团总支提前到场,根据商家和圣诞的`气氛进行布置。团总支所有男生负责搭建舞台(三)各系分工安排。"
    
     },
    
     {
    
         "ItemContent": "5. 参会期间,住宿和往返交通费用请您自理。"
    
     },
    
     {
    
         "ItemContent": "请点击 报名入口 进入会议报名系统,根据系统提示填写相关信息并于 2024 年 12 月 22 日 前提交。如果您不参加本次会议,也请登录系统进行反馈。如有任何问题,请及时与我们联系。",
    
         "ContentStyleList": [
    
             {
    
                 "Content": "报名入口",
    
                 "LinkUrl": "https://www.bejson.com/",
    
                 "Bold": false,
    
                 "StrikingColor": false
    
             },
    
             {
    
                 "Content": "2024 年 12 月 22 日",
    
                 "LinkUrl": "",
    
                 "Bold": false,
    
                 "StrikingColor": true
    
             }
    
         ]
    
     }
    
     ]
    
 }
    
    
    
    

四、注意事项

现在主流的办公软件是wps,但是我一开始生成的PDF总是不被WPS识别,后面我咨询了WPS的客户,他们技术人员说目前WPS只支持两种加密算法:RSA和SM2。我之前用的就是RSA,所以我很好奇为啥不被识别,研究了大半天才发现是因为数字签名的格式不正确。

wps识别的是CMS,我用的却是CADES。这里也算是踩过了坑了,希望能给大家带来一些帮助。

五、成品展示

全部评论 (0)

还没有任何评论哟~