【Java】PDF模板生成PDF文档
一、需求背景
客户要求一份文书,文书内容有一些表单项,例如:
1、基本的是和否 (单选框或复选框)
2、备注内容(纯文本信息)
3、单位,机构组织,人员,字典项(下拉选择)
4、用户数字签名(图片信息)
文书的模板是固定不变的,只需要把上述信息写入模板中生成即可
这个模板不是动态的,动态模板是表单数据决定文档内容,这个相反,文档内容决定表单数据
二、技术选型方案
同事给出的方案是自己用Java代码画一份出来,完全使用代码画模板,然后填充数据
但是就给我两天都没有的时间,我自己是否定了这个方案,第一时间不够,第二是我基本上没有用过pdf文档的API操作
我的想法是,模板固定的,那肯定只要丢参数就好了,类型就两种,一个文本,一个图片
三、落地实现
那么这个方案能不能行呢,网上找了找还是挺多的,看起来能行
参考这个文档,我有了一些初步了解
https://blog.csdn.net/u011628753/article/details/131377253
1-1、需要有一个可以编辑PDF,填写表单域的软件
市面上主流的编辑软件我都一个个踩坑了,免费的都会添加水印,如果对文档水印没有特别要求
可以使用 【万兴PDF】【福昕PDF】【WPS自带】,看UI感觉还是万兴的更好
但是要不夹带私货,高保真文档原貌,还是老老实实用 Adobe Acrobat DC Pro吧
1-2、关于Acrobat DC软件本身
Acrobat DC 普通版没有这个功能,一定要Pro版本才支持
我本来心想这破逼软件应该挺好找的,没想到费老大劲才找到最近2019版本的
不记得在哪个链接找到了,我自己的度盘备份了一份,分享出来
链接:https://pan.baidu.com/s/1A8TdcfkFcuh7ngQg41zBRA?pwd=ez0k 提取码:ez0k --来自百度网盘超级会员V6的分享
解压后在目录中双击setup.exe进行安装即可,是已经破解好的
1-3、如何设置PDF表单
先把模板文件用Acrobat DC打开
找到更多工具 - 【准备表单】


第一次进入之后Adobe会自动对空白填写的位置创建表单项
如果部分位置没有自动创建,可以右键手动设置表单项

表单项统一使用文本域,图片也是通过文本域写入(后面细节再说)
1-4、文本类型的设置
简单摸索之后,主要的设置是这几个内容
名称和锁定的作用

字体信息设置

文本排版设置

1-5、图片类型的设置
对于图片的设置,只需要调整文本域的宽高即可
不要怀疑,图片也是用文本域写入的

2-1、Java 关于PDF操作的一些依赖库
在工程里面找依赖太麻烦了,刚找的一篇快速定位依赖的IDEA插件:
https://blog.csdn.net/Dream_Weave/article/details/131383822
我发现这俩就满足了
<dependency>
<groupId>com.itextpdf</groupId>
<artifactId>itextpdf</artifactId>
<version>${itextpdf.version}</version>
</dependency>
<dependency>
<groupId>com.itextpdf</groupId>
<artifactId>itext-asian</artifactId>
<version>${itext-asian.version}</version>
</dependency>
版本号:
<itextpdf.version>5.5.13.3</itextpdf.version> <itext-asian.version>5.2.0</itext-asian.version>
2-2、封装改良
网上内容编写的API都没有进行简单封装,不能满足业务开发的需求,需要自己封装改良
一、首先得有个基本参数对象,一个表单项即一个对象
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public static final class PdfFormMap {
/* 对应的表单域键名 */
private String fieldKey;
/* 对应类型, 1 文本 2 图片 */
private PdfFieldType fieldType;
/* 文本值 */
private String text;
/* 图片内容 */
private byte[] imageCtx;
/* 自定义宽高 */
private Float customWidth;
private Float customHeight;
}
二、明确参数类型
目前只有文本和图片两种类型,用枚举来准确描述类型
@Getter
public static enum PdfFieldType {
TEXT("文本", 1),
IMAGE("图片", 2);
private final String name;
private final Integer type;
PdfFieldType(String name, Integer type) {
this.name = name;
this.type = type;
}
}
三、方法实现:
业务逻辑只需要包装表单项的数据即可
@SneakyThrows
public static void writeFormDataToPdf(PdfReader reader, PdfStamper pdfStamper, List<PdfFormMap> formMapList) {
AcroFields acroFields = pdfStamper.getAcroFields();
// 需要设置字体,否则中文无法被输出到PDF上,这里就不处理这个逻辑了
// BaseFont.NOT_EMBEDDED 不把字体文件嵌入pdf,但是系统没有该字体将无法正常查看...
// BaseFont bf = BaseFont.createFont("C:\\Windows\\Fonts\\STFANGSO.TTF", BaseFont.IDENTITY_H, BaseFont.NOT_EMBEDDED);
// acroFields.addSubstitutionFont(bf);
/* 获取每页的内容字节对象, 一页对应一个内容字节对象, 因为需要把图片写入,这里先用list保存每一页的对象 */
int totalPage = reader.getNumberOfPages();
List<PdfContentByte> pageCttByteList = new ArrayList<>(totalPage);
/* 定位下标从1开始计算 */
for (int pageIdx = 1; pageIdx <= totalPage; pageIdx++) {
PdfContentByte pageContentByte = pdfStamper.getOverContent(pageIdx);
pageCttByteList.add(pageContentByte);
}
for (PdfFormMap formMap : formMapList) {
String fieldKey = formMap.getFieldKey();
/* 判断这个表单项在模板是否存在,不存在的表单项不设置 */
AcroFields.Item fieldItem = acroFields.getFieldItem(fieldKey);
if (Objects.isNull(fieldItem)) continue;
/* 根据类型设置对应的值 */
PdfFieldType fieldType = formMap.getFieldType();
switch (fieldType) {
case TEXT:
acroFields.setField(fieldKey, formMap.getText());
break;
case IMAGE:
/* 图片存在于多个位置,每一页的每一个位置都有自己的矩阵信息,定位,宽高 */
List<AcroFields.FieldPosition> positions = acroFields.getFieldPositions(fieldKey);
/* 读取图片字节重新转换成PDF图片对象 */
Image image = Image.getInstance(formMap.getImageCtx());
boolean usingCustomSetting = Objects.nonNull(formMap.customHeight) && Objects.nonNull(formMap.customWidth);
for (AcroFields.FieldPosition position : positions) {
/* 获取具体要输出的那一页的内容字节对象 */
PdfContentByte contentByte = pageCttByteList.get(position.page - 1);
/* 图片域的矩阵对象信息 */
Rectangle rectangle = position.position;
float x = rectangle.getLeft();
float y = rectangle.getBottom();
/* 是否使用自定义宽高设置, 具体如何没有尝试过... 逻辑待优化 */
if (usingCustomSetting) contentByte.addImage(image, formMap.customWidth, 0F, 0F, formMap.customHeight, x, y);
/* 使用表单域设置的宽高进行填充 */
else contentByte.addImage(image, rectangle.getWidth(), 0F, 0F, rectangle.getHeight(), x, y);
}
break;
default:
continue;
}
}
}
四、测试Demo
@SneakyThrows
public static void main(String[] args) {
/* 模板路径,输出路径,图片路径 */
String templatePath = "C:\\Users\\Administrator\\Desktop\\pdf-test\\template-new.pdf";
String outputPath = "C:\\Users\\Administrator\\Desktop\\pdf-test\\template-output.pdf";
String orgSignPath = "C:\\Users\\Administrator\\Desktop\\pdf-test\\signature-1.png";
/* 创建资源操作对象 读取,输出,表单操作 */
PdfReader pdfReader = new PdfReader(templatePath);
OutputStream outputStream = Files.newOutputStream(Paths.get(outputPath));
PdfStamper pdfStamper = new PdfStamper(pdfReader, outputStream);
/* 简单设置两个表单项,文本,和图片 */
List<PdfTemplateUtil.PdfFormMap> pdfFormMaps = new ArrayList<>();
PdfTemplateUtil.PdfFormMap orgName = PdfTemplateUtil.PdfFormMap
.builder()
.fieldKey("orgName")
.fieldType(PdfTemplateUtil.PdfFieldType.TEXT)
.text("被检查单位xxxx")
.build();
PdfTemplateUtil.PdfFormMap orgSignImg = PdfTemplateUtil.PdfFormMap
.builder()
.fieldKey("orgSignImg")
.fieldType(PdfTemplateUtil.PdfFieldType.IMAGE)
.imageCtx(FileUtil.readBytes(orgSignPath))
.build();
pdfFormMaps.add(orgName);
pdfFormMaps.add(orgSignImg);
/* 将数据写入pdf中 */
PdfTemplateUtil.writeFormDataToPdf(pdfReader, pdfStamper, pdfFormMaps);
/* 锁定表单和资源释放 */
pdfStamper.setFormFlattening(true);
pdfStamper.close();
outputStream.close();
pdfReader.close();
}
文本的没啥好展示的(记得追加字体配置逻辑)
主要是图片这块,图片会按照文本域的宽高渲染,本身宽高是不会改变的(之前的Excel写入图片同理)

五、工具类完整代码:
package jnpf.util;
import com.itextpdf.text.Image;
import com.itextpdf.text.Rectangle;
import com.itextpdf.text.pdf.AcroFields;
import com.itextpdf.text.pdf.PdfContentByte;
import com.itextpdf.text.pdf.PdfReader;
import com.itextpdf.text.pdf.PdfStamper;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.*;
import lombok.extern.slf4j.Slf4j;
import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/**
* @description Pdf表单域写入工具类
* @author OnCloud9
* @date 2024/4/3 14:17
* @params
* @return
*/
@Slf4j
public class PdfTemplateUtil {
@SneakyThrows
public static void writeFormDataToPdf(PdfReader reader, PdfStamper pdfStamper, List<PdfFormMap> formMapList) {
AcroFields acroFields = pdfStamper.getAcroFields();
// 需要设置字体,否则中文无法被输出到PDF上,这里就不处理这个逻辑了
// BaseFont.NOT_EMBEDDED 不把字体文件嵌入pdf,但是系统没有该字体将无法正常查看...
// BaseFont bf = BaseFont.createFont("C:\\Windows\\Fonts\\STFANGSO.TTF", BaseFont.IDENTITY_H, BaseFont.NOT_EMBEDDED);
// acroFields.addSubstitutionFont(bf);
/* 获取每页的内容字节对象, 一页对应一个内容字节对象, 因为需要把图片写入,这里先用list保存每一页的对象 */
int totalPage = reader.getNumberOfPages();
List<PdfContentByte> pageCttByteList = new ArrayList<>(totalPage);
/* 定位下标从1开始计算 */
for (int pageIdx = 1; pageIdx <= totalPage; pageIdx++) {
PdfContentByte pageContentByte = pdfStamper.getOverContent(pageIdx);
pageCttByteList.add(pageContentByte);
}
for (PdfFormMap formMap : formMapList) {
String fieldKey = formMap.getFieldKey();
/* 判断这个表单项在模板是否存在,不存在的表单项不设置 */
AcroFields.Item fieldItem = acroFields.getFieldItem(fieldKey);
if (Objects.isNull(fieldItem)) continue;
/* 根据类型设置对应的值 */
PdfFieldType fieldType = formMap.getFieldType();
switch (fieldType) {
case TEXT:
acroFields.setField(fieldKey, formMap.getText());
break;
case IMAGE:
/* 图片存在于多个位置,每一页的每一个位置都有自己的矩阵信息,定位,宽高 */
List<AcroFields.FieldPosition> positions = acroFields.getFieldPositions(fieldKey);
/* 读取图片字节重新转换成PDF图片对象 */
Image image = Image.getInstance(formMap.getImageCtx());
boolean usingCustomSetting = Objects.nonNull(formMap.customHeight) && Objects.nonNull(formMap.customWidth);
for (AcroFields.FieldPosition position : positions) {
/* 获取具体要输出的那一页的内容字节对象 */
PdfContentByte contentByte = pageCttByteList.get(position.page - 1);
/* 图片域的矩阵对象信息 */
Rectangle rectangle = position.position;
float x = rectangle.getLeft();
float y = rectangle.getBottom();
/* 是否使用自定义宽高设置, 具体如何没有尝试过... 逻辑待优化 */
if (usingCustomSetting) contentByte.addImage(image, formMap.customWidth, 0F, 0F, formMap.customHeight, x, y);
/* 使用表单域设置的宽高进行填充 */
else contentByte.addImage(image, rectangle.getWidth(), 0F, 0F, rectangle.getHeight(), x, y);
}
break;
default:
continue;
}
}
}
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public static final class PdfFormMap {
/* 对应的表单域键名 */
private String fieldKey;
/* 对应类型, 1 文本 2 图片 */
private PdfFieldType fieldType;
/* 文本值 */
private String text;
/* 图片内容 */
private byte[] imageCtx;
/* 自定义宽高 */
private Float customWidth;
private Float customHeight;
}
@Getter
public static enum PdfFieldType {
TEXT("文本", 1),
IMAGE("图片", 2);
private final String name;
private final Integer type;
PdfFieldType(String name, Integer type) {
this.name = name;
this.type = type;
}
}
}
四、下载与预览
事情到这还没走完业务流程,生成后还需要提供预览下载功能
最开始想到的办法是直接写下载接口,但是下载接口不一定对参数和客户端友好
1-1、存在的问题
存在我每次都头疼的问题:
1、一般下载都是Get请求,令牌,参数信息就直接暴露,而且拼接param也很麻烦,还要考虑编解码和特殊字符
get 请求对应到浏览器的处理是直接window.open(下载地址,’_blank‘)
2、如果参数不够传,就要考虑使用Post请求了,而Post请求在现在的前端工程里面基本是被axios接管的
响应的附件数据会被axios的拦截器拦截,取不到标准的响应code自动视为接口异常
当然,这个可以重写axios的拦截器,但是很变扭
1-2、更好的方案
基于当前的项目工程存在一个文件服务和API,同事有个更好的办法,就是不直接提供文件资源
先把生成的文件上传到文件服务,再经由文件服务返回的信息提供下载和预览的位置
文件服务有特定的上传规则,先得写一份临时文件到 /tmp上
再通过临时文件上传到文件服务指定的位置,完成后再删除临时文件
1-3、预览样例:
暂时没有做太复杂的逻辑,只要能打开就算赢
PC端就简单的多:
async openPdfView() {
let _data = this.dataList()
const res = await getCheckPdfWithData(_data)
window.open(res.data.viewUrl,'_blank')
},
H5 + 小程序端:
async openPdfPreview() {
const df = this.dataFormPack()
const data = await createPdfInfo(df)
console.log(data)
const fileId = new Date().getTime()
/* #ifdef H5 */
// window.open(data.viewUrl)
const downloadLink = document.createElement('a')
downloadLink.href = data.viewUrl
downloadLink.download = `demo-${fileId}.pdf` // 设置下载的文件名
downloadLink.target = '_blank'
downloadLink.style.display = 'none'
document.body.appendChild(downloadLink);
downloadLink.click();
downloadLink.remove(); // 下载之后把创建的元素删除
/* #endif */
/* #ifdef MP */
uni.openDocument({
filePath: data.viewUrl,
success: function(res) {
// console.log('打开文档成功');
}
});
/* #endif */
},
五、解决签名图片透明化问题:
在上面2-2改良封装的测试中,写入的图片是100%颜色的,但是实际上签名图片只需要签字即可
也就是无背景色,在HTML中默认是无色的,但是图片也没法查看

所以是这样,H5端和PC端同一颜色为白色:
PC端追加颜色配置
// 设置背景色为白色 ctx.fillStyle = 'white' // 使用fillRect方法填充整个画布 ctx.fillRect(0, 0, canvas.width, canvas.height)
H5端用的uni的api默认就是白色,所以不用设置了
这样H5端和PC端预览可以看见明显的字迹:

而在生成PDF的时候,再对图像进行透明化转换处理,兼顾两边的需求了
Java 转换透明化的处理,这个方法追加到上面的工具类中
因为图片文件在内存的交互统一使用字节数组,不需要浪费资源写到磁盘上操作
正好看到有ByteArray的IO流,挺方便的
注意,是根据背景色匹配进行替换的,对图片的每一个像素点进行判断
/**
* @description 获取透明化背景的图片
* @author OnCloud9
* @date 2024/4/8 14:39
* @params
* @return
*/
@SneakyThrows
public static byte[] getTransparencyBackgroundImage(byte[] sourceByte) {
InputStream baIs = new ByteArrayInputStream(sourceByte);
BufferedImage buffImg = ImageIO.read(baIs);
int height = buffImg.getHeight();
int width = buffImg.getWidth();
/* 1 创建一个带有透明度的BufferedImage */
BufferedImage newBuffImg = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
Graphics2D g2d = newBuffImg.createGraphics();
/* 2 设置渲染提示以改善图像质量 */
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
g2d.setRenderingHint(RenderingHints.KEY_ALPHA_INTERPOLATION, RenderingHints.VALUE_ALPHA_INTERPOLATION_QUALITY);
/* 3 将原始图像画到带有透明度的BufferedImage上 */
g2d.drawImage(buffImg, 0, 0, null);
g2d.dispose();
/* 4 遍历图片像素,将白色背景设置为透明 */
for (int x = 0; x < width; x++) {
for (int y = 0; y < height; y++) {
int rgb = newBuffImg.getRGB(x, y);
/* 假设背景色是白色 设置透明度 */
if (rgb == Color.WHITE.getRGB()) newBuffImg.setRGB(x, y, new Color(0, 0, 0, 0).getRGB());
}
}
/* 5 将新的透明化图片转换输出字节数组 */
ByteArrayOutputStream baOs = new ByteArrayOutputStream();
ImageIO.write(newBuffImg, "png", baOs);
byte[] imageBytes = baOs.toByteArray();
baOs.close();
baIs.close();
return imageBytes;
}
实现签字效果:


浙公网安备 33010602011771号