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。这里也算是踩过了坑了,希望能给大家带来一些帮助。
五、成品展示

