【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;
}

  

实现签字效果:

 

posted @ 2024-04-04 19:33  emdzz  阅读(698)  评论(0编辑  收藏  举报