项目需求

需要针对逾期客户生成一份公告函,并盖有相关公司的电子章。公告函部分内容需要根据客户信息动态填充。

实现方案

由于对 Java 项目(spring)了解不够深,所以不清楚有什么可选的实现方案。按照领导的指示,请教接入过 法大大 - 线上电子签 的同事。

一开始的设想是,按照电子签的流程,先生成对应的公告函模板文件(PDF),然后上传到法大大,最后调用对应的公司印章,自动自动签署。

按照法大大提供的 使用 Acrobat 制作 PDF 模板说明 文档,先将 word 转为 pdf(wps 就可以,免费),再使用 Adobe Acrobat 软件编辑添加表单,软件会对下划线部分自动生成文本域,可以修改默认的文本域名称以便后续代码写入变量。

因为没有接入过法大大,本来对接应该是比较困难的,但在发现了两点情况之后,让我突然有了新的想法。一个是在使用 Adobe Acrobat 软件的时候发现,还可以在页面上添加图像域,也就是说,可以往 pdf 里添加图片。另一个是同事提供的使用代码中,并没有像我想象中的使用法大大的模板填充接口来填充数据,而是使用了一个自定义的 PDFUtil 助手方法。方法的核心调用的PdfReaderPdfStamper 类来自于一个叫 itextpdf 的类库。

现有一个可以操作 pdf 的类库,并且可以添加图片,这意味着使用这个类库而非法大大的接口来生成一份不需要签字的公告函会更加的简便。在多方参考依赖库和 demo 代码,以及不断地调试解决出现的报错之后,有了当前的这样的方案。

代码实现

准备工作

需要添加了表单的 PDF 模板和电子印章(透明底 png)。

依赖库

        <!-- pdf 操作 jar -->
        <dependency>
            <groupId>com.itextpdf</groupId>
            <artifactId>itextpdf</artifactId>
            <version>5.5.13.3</version>
        </dependency>
        <dependency>
            <groupId>com.itextpdf</groupId>
            <artifactId>itext-asian</artifactId>
            <version>5.2.0</version>
        </dependency>

可以根据需要选择可能更新的版本。itextpdf 是基础操作库,itext-asian 是支持中文写入。如果版本过低,或者没有添加 itext-asian,可能会报错:

Font 'STSong-Light' with 'UniGB-UCS2-H' is not recognized.

插件配置

对于添加到 resources 目录下的 pdf 文件,打包时会对文件进行编译,导致文件被破坏。会导致报错:

com.itextpdf.text.exceptions.InvalidPdfException: Rebuild failed: trailer not found.; Original message: xref subsection not found at file pointer 846942

需要添加例外:

            <!--解决文件打包编译字体、文件损坏-->
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-resources-plugin</artifactId>
                <version>3.1.0</version>
                <configuration>
                    <encoding>UTF-8</encoding>
                    <nonFilteredFileExtensions>
                        <nonFilteredFileExtension>ttf</nonFilteredFileExtension>
                        <nonFilteredFileExtension>woff</nonFilteredFileExtension>
                        <nonFilteredFileExtension>woff2</nonFilteredFileExtension>
                        <nonFilteredFileExtension>xls</nonFilteredFileExtension>
                        <nonFilteredFileExtension>xlsx</nonFilteredFileExtension>
                        <nonFilteredFileExtension>pdf</nonFilteredFileExtension>
                    </nonFilteredFileExtensions>
                </configuration>
            </plugin>

PDFUtil 操作类

使用若依后台,它实现基于 spring boot 框架可以快速搭建 rbac 的后台,还有基础的数据库、redis 以及很多基础配置项。

PDFUtil.java

package com.ruoyi.web.utils;

import com.alibaba.fastjson2.JSON;
import com.itextpdf.text.DocumentException;
import com.itextpdf.text.Image;
import com.itextpdf.text.Rectangle;
import com.itextpdf.text.pdf.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;

import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
import java.util.Map;

@Component
public class PDFUtil {
    static Logger logger = LoggerFactory.getLogger(PDFUtil.class);

    private static void insertImage(AcroFields form, PdfStamper stamper, String filedName, String url) throws IOException, DocumentException {
        int pageNo = form.getFieldPositions(filedName).get(0).page;
        Rectangle signRect = form.getFieldPositions(filedName).get(0).position;
        float x = signRect.getLeft();
        float y = signRect.getBottom();

        logger.info("insertImage:url:" + url);
        logger.info("insertImage:pageNo:" + pageNo);
        logger.info("insertImage:x,y:" + x + "," + y);
        logger.info("insertImage:w,h:" + signRect.getWidth() + "," + signRect.getHeight());

        Image image = Image.getInstance(url);
        // 获取操作的页面
        PdfContentByte under = stamper.getOverContent(pageNo);
        // 根据域的大小缩放图片
        image.scaleToFit(signRect.getWidth(), signRect.getHeight());
        // 添加图片
        image.setAbsolutePosition(x, y);
        under.addImage(image);
    }

    private static void addImageToPdf(AcroFields form, PdfStamper stamper, String filedName, String url) throws DocumentException, IOException {

        // 通过域名获取所在页和坐标,左下角为起点
        int pageNo = form.getFieldPositions(filedName).get(0).page;
        Rectangle signRect = form.getFieldPositions(filedName).get(0).position;
        float x = signRect.getLeft()+signRect.getRight();
        float y = signRect.getTop();

        logger.info("insertImage:url:" + url);
        logger.info("insertImage:pageNo:" + pageNo);
        logger.info("insertImage:x,y:" + x + "," + y);
        logger.info("insertImage:w,h:" + signRect.getWidth() + "," + signRect.getHeight());

        // 读图片
        Image image = Image.getInstance(url);
        // 获取操作的页面
        PdfContentByte under = stamper.getOverContent(pageNo);
        // 根据域的大小缩放图片
//        image.scaleToFit(signRect.getWidth()/4, signRect.getHeight()/4);
        image.scaleToFit(signRect.getWidth(), signRect.getHeight());
        // 添加图片并设置位置(个人通过此设置使得图片垂直水平居中,可参考,具体情况已实际为准)
        image.setAbsolutePosition(x/2-signRect.getWidth()/4, y/2);
        under.addImage(image);
    }

    public static Boolean pdfTemplateInsert(String templateUrl, String outputFileUrl, Map<String, String> templateValueMap, Map<String, String> templateImageMap) {
        logger.info("-> 替换pdf模板变量 -- 模板地址:" + templateUrl + ";保存地址:" + outputFileUrl + ";替换参数:" + JSON.toJSONString(templateValueMap));
        boolean success = true;

        OutputStream os = null;
        PdfStamper ps = null;
        PdfReader reader = null;
        try {
            logger.info("templateUrl:" + templateUrl);
            logger.info("outputFileUrl:" + outputFileUrl);
            os = Files.newOutputStream(new File(outputFileUrl).toPath());
            logger.info("outputFileUrl:" + os);
            //读取pdf表单
            reader = new PdfReader(templateUrl);
            //根据表单生成一个新的pdf文件
            ps = new PdfStamper(reader, os);
            //获取pdf表单
            AcroFields form = ps.getAcroFields();
            //给表单中添加中文字体
            BaseFont bf = BaseFont.createFont("STSong-Light", "UniGB-UCS2-H", BaseFont.NOT_EMBEDDED);
            form.addSubstitutionFont(bf);
            if (null != templateValueMap && !templateValueMap.isEmpty()) {
                for (String key : templateValueMap.keySet()) {
                    form.setField(key, String.valueOf(templateValueMap.get(key)));
                }
            }

            if (null != templateImageMap && !templateImageMap.isEmpty()) {
                for (String key : templateImageMap.keySet()) {
                    addImageToPdf(form, ps, key, templateImageMap.get(key));
                }
            }
            ps.setFormFlattening(true);
        } catch (Exception e) {
            logger.error("替换变量异常!", e);
            success = false;
        } finally {
            try {
                if (ps != null) {
                    ps.close();
                }
                if (reader != null) {
                    reader.close();
                }
                if (os != null) {
                    os.close();
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        return success;
    }
}

主要是函数

public static Boolean pdfTemplateInsert(String templateUrl, String outputFileUrl, Map<String, String> templateValueMap, Map<String, String> templateImageMap);
参数说明
templateUrlPDF模板文件地址
outputFileUrl输出文件(PDF)地址
templateValueMap模板变量文字域Map
templateImageMap模板变量图像域Map

控制器调用

    @Log(title = "测试 PDF 编辑")
    @PostMapping("/getPdf")
    public AjaxResult getPdf(@RequestParam(defaultValue = "shzb") String company, @RequestParam(defaultValue = "cgh") String type)
    {
        Map<String, String> map = new HashMap<>();
        map.put("clientName", "x海");
        map.put("clientAddress", "江苏省苏州市xxx");
        map.put("signYear", "2022");
        map.put("signMonth", "12");
        map.put("signDay", "09");
        map.put("remaining", "59000");
        map.put("sendYear", DateUtils.format(new Date(), "yyyy"));
        map.put("sendMonth", DateUtils.format(new Date(), "MM"));
        map.put("sendDay", DateUtils.format(new Date(), "dd"));
        Map<String, String> imageMap = new HashMap<>();
        String path = this.getClass().getClassLoader().getResource("").getPath();//注意getResource("")里面是空字符串
        System.out.println(path);
        String imgPath = path + "/contract/" + company + ".png";
        System.out.println(imgPath);
        imageMap.put("companyImage", imgPath);
        String templateTag = (type + "_" + company).toUpperCase();
        String outputUrl = fullFillTemplate(templateTag, map, imageMap);
        return AjaxResult.success(outputUrl);
    }

    private String fullFillTemplate(String templateTag, Map<String, String> map, Map<String, String> imageMap) {
        //模板路径
        ContactEnum contactEnum = ContactEnum.getEnum(templateTag);
        String inputUrl = contactEnum.getFilePath() + ".pdf";
        //生成的文件路径
        String outputUrl = "/tmp" + File.separator + map.get("contractNo") + "_" +
                contactEnum.getAttachmentCategory() + "_" + contactEnum.getAttachmentSubclass() +  ".pdf";

        boolean res = PDFUtil.pdfTemplateInsert(inputUrl, outputUrl, map, imageMap);
        return outputUrl;
    }

当然这里的 outputUrl 输出后显然没什么用,要么将其放置到 URL 可以访问的目录下,要么就像我一样,将其转化为输出流下载。

        File file = new File(outputUrl);
        System.out.println("OutputStream filename:" + file.getName());
        //1、设置response 响应头
        response.reset();
        response.setCharacterEncoding("UTF-8");
//        response.setContentType("application/octet-stream");
        response.setContentType("application/pdf");
        response.setHeader("Content-Disposition",
                "attachment;fileName=" + URLEncoder.encode(file.getName(), "UTF-8"));

        //2、 读取文件--输入流
        InputStream input= Files.newInputStream(file.toPath());
        //3、 写出文件--输出流
        OutputStream out = response.getOutputStream();
        byte[] buff =new byte[1024];
        int index=0;
        //4、执行 写出操作
        while((index= input.read(buff))!= -1){
            out.write(buff, 0, index);
            out.flush();
        }
        out.close();
        input.close();

接口的返回类型,要改成 void,不然会报错:

No converter for xxx with preset Content-Type ‘application/octet-stream;charset=UTF-8

对于 vue 框架的前端,需要将其转化 blob,再通过 a 标签输出下载。

      const link = document.createElement('a')
      let blob = new Blob([res], { type: 'application/pdf' })
      link.style.display = 'none'
      link.href = URL.createObjectURL(blob)
      link.setAttribute('download', decodeURI(linkName))
      document.body.appendChild(link)
      link.click()
      document.body.removeChild(link)

对于本地开发,上面的图片是能够添加到 PDF 模板中,但当在线上 jar 包运行时,以上的读取会有问题:

        String path = this.getClass().getClassLoader().getResource("").getPath();//注意getResource("")里面是空字符串
        System.out.println(path);
        String imgPath = path + "/contract/" + company + ".png";
        System.out.println(imgPath);

线上会报错:FileNotFoundException: /data/xxx-business.jar!/BOOT-INF/classes!/xxx.png,即绝对路径下的图片经过 jar 包目录结构,访问不到。

此时需要修改成流读取,再转化为 ByteArray 作为 Image.getInstance(xx) 的参数。

String imgPath = "contract/" + reminderVo.getCompany() + ".png";

addImageToPdf()

        // 打 jar 包后直接路径获取不到文件,需要转化为流
        ClassPathResource resource = new ClassPathResource(url);
        InputStream inputStreamImg = resource.getInputStream();
        BufferedImage bufferedImage = ImageIO.read(inputStreamImg);
        ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); ImageIO.write(bufferedImage, "png", byteArrayOutputStream);

        // 读图片
        Image image = Image.getInstance(byteArrayOutputStream.toByteArray());

参考:
Java 使用 itext 向PDF插入数据和图片
itextpdf读取PDF文件流报Rebuild failed: trailer not found at file pointer 846942
解决PDF添加水印报错iText: Font 'STSong-Light' with 'UniGB-UCS2-H' is not recognized
如何使用Itext5构建PDF在每页循环插入图片,如何实现?
下载/导出问题(统一返回):No converter for xxx with preset Content-Type ‘application/octet-stream;charset=UTF-8