【java-电子签章功能实现】

java-电子签章方案

本文主要描述如何对已有的word文档进行字段填充后,进行电子签章(CA证书)生成pdf文件

废话不多数,上代码(涉及的工具类较多,有不全的评论即可,看到会及时补充)

1.替换word变量为业务数据并签章

    @Transactional
    @Override
    public  void generatePdf(业务数据对象  info,boolean isNew) {
        try {
            //  
            String generateeLetterUrl = info.getGenerateeLetterUrl();
            // 判断PDF文件的url为空,生成对应url
            if (StringUtils.isBlank(generateeLetterUrl) || isNew) {
                // 获取配置的模板变量值
                // 这里是我自己建了一个本地的表对常用的一些模板变量进行管理,也可以对
                Map<String, String> variableMap = this.getLetterVariableMap(info);
                log.info("根据业务数据组装 map为{}",variableMap);
           

                // 签章文件中参数配置
                variableMap.put(CommonConstant.GUARANTOR_NAME, "业务数据");
                variableMap.put(CommonConstant.GUARANTOR_ADDRESS, "业务数据");
                variableMap.put(CommonConstant.LETTER_ADDRESS,"业务数据");
                variableMap.put(CommonConstant.LETTER_BAOZHENGREN, "业务数据");
                variableMap.put(CommonConstant.LETTER_PHONE, "业务数据");//联系电话
                 
                // 创建文件夹
                if (!FileUtil.exist(letterTempFolder)) {
                    FileUtil.mkdir(letterTempFolder);
                }

                // 定义路径,流是不可重复读取的,所以这边用了笨方法每次对文件操作都使用新的临时文件地址,最后在进行删除
                String tempPdfFileUrl = letterTempFolder + File.separator + info.getGenerateeLetterNo() + CommonConstant.TEMPLATE_PDF;
                // docx临时文件地址
                String docxPdfFilePath = letterTempFolder + File.separator + info.getGenerateeLetterNo() + RandomUtil.randomNumbers(4) + CommonConstant.TEMPLATE_DOCX;

                //签章之后的 文件生成
                String targetPdfFilePath = letterTempFolder + File.separator + info.getGenerateeLetterNo() + RandomUtil.randomNumbers(4) + CommonConstant.TEMPLATE_PDF;

                // 电子签章临时文件地址
                String tempSignPngPath = letterTempFolder + File.separator + info.getGenerateeLetterNo() + CommonConstant.SIGN_PIC;

                InputStream templateStream = null;
                FileOutputStream pdfOutputStream = null;
                InputStream certInputStream = null;
                try {
                    //  这里是因为我这个项目有不同的签章模板,弄了版本号进行区分
                    log.info("第一步:获取模板文件流");
                    Resource resource;
                    if(CommonConstant.LETTERTEMPLATE_VERSION.equals(info.getGenerateeLetterVersion())){
                        resource = new ClassPathResource("letterTemplate.docx");
                    }else if(CommonConstant.LETTERTEMPLATE_VERIFY_VERSION.equals(info.getGenerateeLetterVersion())){
                        resource = new ClassPathResource("letterTemplate3.docx");
                    } else {
                        resource = new ClassPathResource("letterTemplate1.docx");
                    }
                 
                     
                    // 获取word模板的文件流
                    templateStream = resource.getInputStream();
                    if (null != templateStream) {
                        // org.apache.poi.xwpf.usermodel下的
                        XWPFDocument document = new XWPFDocument(templateStream);
                        //模本文件变量分割格式化
                        WordUtils.formatDocument(document);
                        //解析替换文本对象
                        WordUtils.changeText(document, variableMap);

                        try (FileOutputStream outputStream=new FileOutputStream(docxPdfFilePath)){
                            document.write(outputStream);
                        }
                        //生成 模板变量替换之后的pdf文件
                        log.info("第二步:生成替换变量之后的pdf文件");
                        AsposseWordUtils.convertDocxToPdf(docxPdfFilePath,tempPdfFileUrl);
                        // 第三步:生成签章图片
                        log.info("第三步:开始生成签章图片");
                        log.info("第三步:开始生成签章图片");
                        String SignName=CommonConstant.SIGNED_NAME_VALUE;
                        String SignBottomName=CommonConstant.SIGNED_BOTTOM_TITLE;
                        if(系统配置信息!=null && StringUtils.isNotBlank(系统配置信息.getDeptName())){
                            SignName=系统配置信息.getDeptName();
                        }
                        if(系统配置信息!=null && StringUtils.isNotBlank(系统配置信息.getSignCode())){
                            SignBottomName=系统配置信息.getSignCode();
                        }
                        SealUtil.generateDefaultSeal(SignName, SignBottomName, tempSignPngPath);
                        log.info("第三步:生成签章图片成功");
                        
                        // 读取CA证书文件  
                        Resource certResource = new ClassPathResource("cert.pfx");
                        certInputStream = certResource.getInputStream();
                        // 参数为 目标签章文件地址 、签章机构名称、目标pdf存放地址、
                        log.info("第四步:PDF进行签章");
                        String certPassword="CA证书密码";
                        
                        PdfUtil.signPdf(tempPdfFileUrl, 系统配置信息.getDeptName(), targetPdfFilePath, tempSignPngPath, certInputStream, certPassword, signKeyword, 3);

                        log.info("第四步:PDF进行签章成功,地址為{}",targetPdfFilePath);
                        File file = new File(targetPdfFilePath);

                        // 文件上传minio分布式文件存储器
                        log.info("第五步:PDF进行上传");
                        String saveUrl = fileClient.upload(file, FileTypeUrlEnum.LETTER_OF_GUARANTEE.getCode());
                        log.info("第五步:PDF上传成功,地址為{}",saveUrl);

                        // 业务上的自定义操作
                        try {
                            修改主业务信息方法(info);
                            // 文件信息入库
                        } catch (Exception e) {
                            log.error("保存附件信息失败,参数:{},错误:{}", JSONUtil.toJsonStr(guaranteeFile),e);
                        }
                    }
                } catch (Exception e) {
                    throw new ServiceException(e.getMessage());
                } finally {
                    IoUtil.close(templateStream);
                    IoUtil.close(pdfOutputStream);
                    IoUtil.close(certInputStream);
                    log.debug("关闭文件流");
                    //临时文件删除
                    FileUtil.del(tempPdfFileUrl);
                    FileUtil.del(docxPdfFilePath);
                    FileUtil.del(targetPdfFilePath);
                    FileUtil.del(tempSignPngPath);
                    log.debug("删除临时文件");
                }
            }
        }catch (Exception e) {
            log.error(" 生成pdf失败, 信息:{},异常信息:{}",info,e);
        }
    }

2.相关工具类代码

WordUtils

import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
import org.apache.poi.xwpf.usermodel.XWPFRun;

import java.util.List;
import java.util.Map;
import java.util.Set;

/**
 * 通过word模板生成新的word工具类
 */
@Slf4j
public class WordUtils {

	/*public static void main(String[] args) throws IOException {

		FileInputStream inputStream=new FileInputStream(new File("E:\\letter\\doc\\source\\保函样例模板.docx"));
		XWPFDocument document = new XWPFDocument(inputStream);
		formatDocument(document);

		FileOutputStream outputStream=new FileOutputStream(new File("E:\\letter\\doc\\format\\保函样例模板.docx"));
		document.write(outputStream);
	}*/

	public static boolean hasKeyWord(XWPFDocument document, String keyWord){
		List<XWPFParagraph> paragraphs = document.getParagraphs();
		for (XWPFParagraph paragraph : paragraphs) {
			// 获取段落文本
			String text = paragraph.getText();
			if(text.contains(keyWord)){
				return true;
			}
		}
		return false;
	}

	/***
	 *@description:XWPFDocument文档格式化处理
	 * 变量前缀、后缀、变量名称被拆分,需要合并为单一XWPFRun内容
	 *[org.apache.poi.xwpf.usermodel.XWPFDocument]
	 *void
	 *@author: zhaowenchao
	 *@date: 2023/6/26 10:54
	 *
	 */
	public static void formatDocument(XWPFDocument document){
		log.info("=== docment format begin");
		List<XWPFParagraph> paragraphs = document.getParagraphs();
		for (XWPFParagraph paragraph : paragraphs) {
			// 判断此段落时候需要进行替换
			String text = paragraph.getText();  // 获取段落文本
			if (checkText(text)) {
				List<XWPFRun> runs = paragraph.getRuns(); // 获取文本的公共属性集
				forwardFormat(runs);
				reverseFormat(runs);
			}
		}
		log.info("=== docment format end");
	}


	public static void forwardFormat(List<XWPFRun> runs ){
		//XWPFRun内容以$或者${结尾,向后合并
		String preRunValue=null;
		for (int i= 0 ;i<runs.size();i++) { // run 为文本对象
			XWPFRun run  = runs.get(i);
			String runText  = run.getText(0);
			if(!StringUtils.isEmpty(preRunValue)){
				runText=preRunValue+runText;
				run.setText(runText,0);
			}
			if(runText.endsWith("$") ||runText.endsWith("${") ){
				preRunValue=runText;
				run.setText("",0);
			}else{
				preRunValue=null;
			}
		}
	}

	public  static void  reverseFormat(List<XWPFRun> runs ){
		//XWPFRun内容以}开头,向前合并
		String nextRunValue=null;
		for (int j = runs.size()-1;j>0;j--) { // run 为文本对象
			XWPFRun run  = runs.get(j);
			String runText  = run.getText(0);
			if(!StringUtils.isEmpty(nextRunValue)){
				runText=runText+nextRunValue;
				run.setText(runText,0);
			}
			if(runText.startsWith("}")){
				nextRunValue=runText;
				run.setText("",0);
			}else{
				nextRunValue=null;
			}
		}
	}



	public static void changeText(XWPFDocument document, Map<String, String> textMap) {
		log.info("=== changeText  begin=== ");
		log.info("=== textMap参数 === : " + textMap);
		// 获取段落集合
		List<XWPFParagraph> paragraphs = document.getParagraphs();

		for (XWPFParagraph paragraph : paragraphs) {
			// 判断此段落时候需要进行替换
			String text = paragraph.getText();  // 获取段落文本
			if (checkText(text)) {
				List<XWPFRun> runs = paragraph.getRuns(); // 获取文本的公共属性集
				for (XWPFRun run : runs) { // run 为文本对象
					String runText  = run.getText(0);
					run.setText(changeValue(run.toString(), textMap), 0);
				}
			}
		}
		log.info("=== changeText  end=== ");
	}

	public static boolean checkText(String text) {
		boolean check = false;
		if (text.indexOf("$") != -1) {
			check = true;
		}
		return check;

	}

	public static String changeValue(String value, Map<String, String> textMap) {
		Set<Map.Entry<String, String>> textSets = textMap.entrySet();
		for (Map.Entry<String, String> textSet : textSets) {
			String key  =textSet.getKey();
			if (value.indexOf(key) != -1) {
				value = value.replace(key,String.valueOf(textSet.getValue()));
			}
		}
		return value;
	}




}

AsposseWordUtils

import com.aspose.words.Document;
import com.aspose.words.License;
import com.aspose.words.SaveFormat;
import lombok.extern.slf4j.Slf4j;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;

@Slf4j
public class AsposseWordUtils {

    private  static License license = null;

    static {
        try {
            InputStream inputStream = AsposseWordUtils.class.getResourceAsStream("/license.xml");
            license = new License();
            license.setLicense(inputStream);
        }
        catch (Exception e){

            throw new RuntimeException("自动加载aspose证书文件失败");
        }
    }


    public static void convertToPdf(Document document, String targetFile) throws IOException {
        try {
            long old = System.currentTimeMillis();
            File file = new File(targetFile);
            FileOutputStream fileOutputStream = new FileOutputStream(file);
            document.save(fileOutputStream, SaveFormat.PDF);
            fileOutputStream.close();
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }


    public static void convertDocxToPdf(String docxPath, String pdfPath) throws IOException {
        try {
            Document document = new Document(docxPath);
            document.save(pdfPath,SaveFormat.PDF);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
    public static void main(String[] args) throws Exception {
        Document document = new Document("/home/temp/output.docx");
        document.save("/home/temp/out.pdf",SaveFormat.PDF);

    }


}

SealUtil

import com.ruoyi.common.utils.letter.seal.SealCircle;
import com.ruoyi.common.utils.letter.seal.SealConfiguration;
import com.ruoyi.common.utils.letter.seal.SealFont;
import com.sddbjt.boot.framework.common.enums.ErrorCodeEnum;
import com.sddbjt.boot.framework.common.exception.ServiceException;
import lombok.extern.slf4j.Slf4j;

import javax.imageio.ImageIO;
import javax.swing.*;
import java.awt.*;
import java.awt.font.FontRenderContext;
import java.awt.geom.AffineTransform;
import java.awt.geom.Rectangle2D;
import java.awt.image.BufferedImage;
import java.io.*;

/**
 * @Description: 印章工具类
 * @Author
 * @Date:
 */
@Slf4j
public abstract class SealUtil {
    public static void generateDefaultSeal(String mainName,String viceName,String storeUrl){
        SealConfiguration sealConfiguration = new SealConfiguration();
        sealConfiguration.setMainFont(new SealFont(mainName, true, "宋体", 38, 38d, 10))
                .setCenterFont(new SealFont("★", false, "宋体", 60, 60d, 0))
                .setViceFont(new SealFont(viceName, false, "宋体", 15, 15d, 10))
                // 甲方沟通去掉电子签章title
                /*.setTitleFont(new SealFont("电子签章", true, "宋体", 30, 30d, 40))*/
                .setBackgroudColor(Color.RED).setImageSize(320).setBorderCircle(new SealCircle(8, 150, 150));
        SealUtil.buildAndStoreSeal(sealConfiguration,storeUrl);
    };


    /**
     * 默认从10x10的位置开始画,防止左上部分画布装不下
     */
    private final static int INIT_BEGIN = 10;

    /**
     * 生成私人印章图片,并保存到指定路径
     *
     * @param lineSize 边线宽度
     * @param font 字体对象
     * @param addString 追加字符
     * @param fullPath 保存全路径
     *
     */
    public static void buildAndStorePersonSeal(int imageSize, int lineSize, SealFont font, String addString,
                                               String fullPath) throws Exception {
        storeBytes(buildBytes(buildPersonSeal(imageSize, lineSize, font, addString)), fullPath);
    }

    /**
     * 生成印章图片,并保存到指定路径
     *
     * @param conf 配置文件F
     * @param fullPath 保存全路径
     *
     */
    public static void buildAndStoreSeal(SealConfiguration conf, String fullPath) {

        try {
            storeBytes(buildBytes(buildSeal(conf)), fullPath);
        } catch (Exception e) {
            log.error("buildAndStoreSeal e :{}",e.getMessage(),e);
            throw new ServiceException(ErrorCodeEnum.SYSTEM_ERROR.getCode(),"build  store seal error");
        }
    }

    /**
     * 生成印章图片的inputstream
     * @param image BufferedImage对象
     * @return
     * @throws Exception
     */
    public static InputStream buildInputStream(BufferedImage image) throws Exception {
        return new ByteArrayInputStream(buildBytes(image));
    }

    /**
     * 生成印章图片的inputstream
     * @param conf 配置文件
     * @return
     * @throws Exception
     */
    public static InputStream buildInputStream(SealConfiguration conf) throws Exception {
        return buildInputStream(buildSeal(conf));
    }

    /**
     * 生成印章图片的byte数组
     *
     * @param image BufferedImage对象
     *
     * @return byte数组
     *
     * @throws IOException 异常
     */
    public static byte[] buildBytes(BufferedImage image) throws Exception {

        try (ByteArrayOutputStream outStream = new ByteArrayOutputStream()) {
            //bufferedImage转为byte数组
            ImageIO.write(image, "png", outStream);
            return outStream.toByteArray();
        }
    }

    /**
     * 生成印章图片
     *
     * @param conf 配置文件
     *
     * @return BufferedImage对象
     *
     * @throws Exception 异常
     */
    public static BufferedImage buildSeal(SealConfiguration conf) throws Exception {

        //1.画布
        BufferedImage bi = new BufferedImage(conf.getImageSize(), conf.getImageSize(), BufferedImage.TYPE_4BYTE_ABGR);

        //2.画笔
        Graphics2D g2d = bi.createGraphics();

        //2.1抗锯齿设置
        //文本不抗锯齿,否则圆中心的文字会被拉长
        RenderingHints hints = new RenderingHints(RenderingHints.KEY_TEXT_ANTIALIASING,
                RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
        //其他图形抗锯齿
        hints.put(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
        g2d.setRenderingHints(hints);

        //2.2设置背景透明度
        g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_ATOP, 1f));

        //2.3填充矩形
        g2d.fillRect(0, 0, conf.getImageSize(), conf.getImageSize());

        //2.4重设透明度,开始画图
        g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER));

        //2.5设置画笔颜色
        g2d.setPaint(conf.getBackgroudColor());

        //3.画边线圆
        if (conf.getBorderCircle() != null) {
            drawCicle(g2d, conf.getBorderCircle(), INIT_BEGIN, INIT_BEGIN);
        } else {
            throw new Exception("BorderCircle can not null!");
        }

        int borderCircleWidth = conf.getBorderCircle().getWidth();
        int borderCircleHeight = conf.getBorderCircle().getHeight();

        //4.画内边线圆
        if (conf.getBorderInnerCircle() != null) {
            int x = INIT_BEGIN + borderCircleWidth - conf.getBorderInnerCircle().getWidth();
            int y = INIT_BEGIN + borderCircleHeight - conf.getBorderInnerCircle().getHeight();
            drawCicle(g2d, conf.getBorderInnerCircle(), x, y);
        }

        //5.画内环线圆
        if (conf.getInnerCircle() != null) {
            int x = INIT_BEGIN + borderCircleWidth - conf.getInnerCircle().getWidth();
            int y = INIT_BEGIN + borderCircleHeight - conf.getInnerCircle().getHeight();
            drawCicle(g2d, conf.getInnerCircle(), x, y);
        }

        //6.画弧形主文字
        if (borderCircleHeight != borderCircleWidth) {
            drawArcFont4Oval(g2d, conf.getBorderCircle(), conf.getMainFont(), true);
        } else {
            drawArcFont4Circle(g2d, borderCircleHeight, conf.getMainFont(), true);
        }

        //7.画弧形副文字
        if (borderCircleHeight != borderCircleWidth) {
            drawArcFont4Oval(g2d, conf.getBorderCircle(), conf.getViceFont(), false);
        } else {
            drawArcFont4Circle(g2d, borderCircleHeight, conf.getViceFont(), false);
        }

        //8.画中心字
        drawFont(g2d, (borderCircleWidth + INIT_BEGIN) * 2, (borderCircleHeight + INIT_BEGIN) * 2,
                conf.getCenterFont());

        //9.画抬头文字
        drawFont(g2d, (borderCircleWidth + INIT_BEGIN) * 2, (borderCircleHeight + 22) * 2, conf.getTitleFont());

        g2d.dispose();
        return bi;
    }

    /**
     * 生成私人印章图片
     *
     * @param lineSize 线条粗细
     * @param font 字体对象
     * @param addString 是否添加文字,如“印”
     *
     * @return BufferedImage对象
     *
     * @throws Exception 异常
     */
    public static BufferedImage buildPersonSeal(int imageSize, int lineSize, SealFont font, String addString)
            throws Exception {
        if (font == null || font.getFontText().length() < 2 || font.getFontText().length() > 4) {
            throw new Exception("FontText.length illegal!");
        }

        int fixH = 18;
        int fixW = 2;

        //1.画布
        BufferedImage bi = new BufferedImage(imageSize, imageSize / 2, BufferedImage.TYPE_4BYTE_ABGR);

        //2.画笔
        Graphics2D g2d = bi.createGraphics();

        //2.1设置画笔颜色
        g2d.setPaint(Color.RED);

        //2.2抗锯齿设置
        g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);

        //3.写签名
        int marginW = fixW + lineSize;
        float marginH;
        FontRenderContext context = g2d.getFontRenderContext();
        Rectangle2D rectangle;
        Font f;

        if (font.getFontText().length() == 2) {
            if (addString != null && addString.trim().length() > 0) {
                bi = drawThreeFont(bi, g2d, font.setFontText(font.getFontText() + addString), lineSize, imageSize, fixH,
                        fixW, true);
            } else {
                f = new Font(font.getFontFamily(), Font.BOLD, font.getFontSize());
                g2d.setFont(f);
                rectangle = f.getStringBounds(font.getFontText().substring(0, 1), context);
                marginH = (float) (Math.abs(rectangle.getCenterY()) * 2 + marginW) + fixH - 4;
                g2d.drawString(font.getFontText().substring(0, 1), marginW, marginH);
                marginW += Math.abs(rectangle.getCenterX()) * 2 + (font.getFontSpace() == null ?
                        INIT_BEGIN :
                        font.getFontSpace());
                g2d.drawString(font.getFontText().substring(1), marginW, marginH);

                //拉伸
                BufferedImage nbi = new BufferedImage(imageSize, imageSize, bi.getType());
                Graphics2D ng2d = nbi.createGraphics();
                ng2d.setPaint(Color.RED);
                ng2d.drawImage(bi, 0, 0, imageSize, imageSize, null);

                //画正方形
                ng2d.setStroke(new BasicStroke(lineSize));
                ng2d.drawRect(0, 0, imageSize, imageSize);
                ng2d.dispose();
                bi = nbi;
            }
        } else if (font.getFontText().length() == 3) {
            if (addString != null && addString.trim().length() > 0) {
                bi = drawFourFont(bi, font.setFontText(font.getFontText() + addString), lineSize, imageSize, fixH,
                        fixW);
            } else {
                bi = drawThreeFont(bi, g2d, font.setFontText(font.getFontText()), lineSize, imageSize, fixH, fixW,
                        false);
            }
        } else {
            bi = drawFourFont(bi, font, lineSize, imageSize, fixH, fixW);
        }

        return bi;
    }

    /**
     * 将byte数组保存为本地文件
     *
     * @param buf byte数组
     * @param fullPath 文件全路径
     *
     * @throws IOException 异常
     */
    private static void storeBytes(byte[] buf, String fullPath) throws IOException {

        File file = new File(fullPath);
        try (FileOutputStream fos = new FileOutputStream(file);
                BufferedOutputStream bos = new BufferedOutputStream(fos)) {

            //1.如果父目录不存在,则创建
            File dir = file.getParentFile();
            if (!dir.exists()) {
                dir.mkdirs();
            }

            //2.写byte数组到文件
            bos.write(buf);
        }
    }

    /**
     * 画三字
     *
     * @param bi 图片
     * @param g2d 原画笔
     * @param font 字体对象
     * @param lineSize 线宽
     * @param imageSize 图片尺寸
     * @param fixH 修复膏
     * @param fixW 修复宽
     * @param isWithYin 是否含有“印”
     */
    private static BufferedImage drawThreeFont(BufferedImage bi, Graphics2D g2d, SealFont font, int lineSize,
            int imageSize, int fixH, int fixW, boolean isWithYin) {
        fixH -= 9;
        int marginW = fixW + lineSize;
        //设置字体
        Font f = new Font(font.getFontFamily(), Font.BOLD, font.getFontSize());
        g2d.setFont(f);
        FontRenderContext context = g2d.getFontRenderContext();
        Rectangle2D rectangle = f.getStringBounds(font.getFontText().substring(0, 1), context);
        float marginH = (float) (Math.abs(rectangle.getCenterY()) * 2 + marginW) + fixH;
        int oldW = marginW;

        if (isWithYin) {
            g2d.drawString(font.getFontText().substring(2, 3), marginW, marginH);
            marginW += rectangle.getCenterX() * 2 + (font.getFontSpace() == null ? INIT_BEGIN : font.getFontSpace());
        } else {
            marginW += rectangle.getCenterX() * 2 + (font.getFontSpace() == null ? INIT_BEGIN : font.getFontSpace());
            g2d.drawString(font.getFontText().substring(0, 1), marginW, marginH);
        }

        //拉伸
        BufferedImage nbi = new BufferedImage(imageSize, imageSize, bi.getType());
        Graphics2D ng2d = nbi.createGraphics();
        ng2d.setPaint(Color.RED);
        ng2d.drawImage(bi, 0, 0, imageSize, imageSize, null);

        //画正方形
        ng2d.setStroke(new BasicStroke(lineSize));
        ng2d.drawRect(0, 0, imageSize, imageSize);
        ng2d.dispose();
        bi = nbi;

        g2d = bi.createGraphics();
        g2d.setPaint(Color.RED);
        g2d.setFont(f);
        g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);

        if (isWithYin) {
            g2d.drawString(font.getFontText().substring(0, 1), marginW, marginH += fixH);
            rectangle = f.getStringBounds(font.getFontText(), context);
            marginH += Math.abs(rectangle.getHeight());
            g2d.drawString(font.getFontText().substring(1), marginW, marginH);
        } else {
            g2d.drawString(font.getFontText().substring(1, 2), oldW, marginH += fixH);
            rectangle = f.getStringBounds(font.getFontText(), context);
            marginH += Math.abs(rectangle.getHeight());
            g2d.drawString(font.getFontText().substring(2, 3), oldW, marginH);
        }
        return bi;
    }

    /**
     * 画四字
     *
     * @param bi 图片
     * @param font 字体对象
     * @param lineSize 线宽
     * @param imageSize 图片尺寸
     * @param fixH 修复膏
     * @param fixW 修复宽
     */
    private static BufferedImage drawFourFont(BufferedImage bi, SealFont font, int lineSize, int imageSize, int fixH,
            int fixW) {
        int marginW = fixW + lineSize;
        //拉伸
        BufferedImage nbi = new BufferedImage(imageSize, imageSize, bi.getType());
        Graphics2D ng2d = nbi.createGraphics();
        ng2d.setPaint(Color.RED);
        ng2d.drawImage(bi, 0, 0, imageSize, imageSize, null);

        //画正方形
        ng2d.setStroke(new BasicStroke(lineSize));
        ng2d.drawRect(0, 0, imageSize, imageSize);
        ng2d.dispose();
        bi = nbi;

        Graphics2D g2d = bi.createGraphics();
        g2d.setPaint(Color.RED);
        g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
        FontRenderContext context = g2d.getFontRenderContext();

        Font f = new Font(font.getFontFamily(), Font.BOLD, font.getFontSize());
        g2d.setFont(f);
        Rectangle2D rectangle = f.getStringBounds(font.getFontText().substring(0, 1), context);
        float marginH = (float) (Math.abs(rectangle.getCenterY()) * 2 + marginW) + fixH;

        g2d.drawString(font.getFontText().substring(2, 3), marginW, marginH);
        int oldW = marginW;
        marginW +=
                Math.abs(rectangle.getCenterX()) * 2 + (font.getFontSpace() == null ? INIT_BEGIN : font.getFontSpace());

        g2d.drawString(font.getFontText().substring(0, 1), marginW, marginH);
        marginH += Math.abs(rectangle.getHeight());

        g2d.drawString(font.getFontText().substring(3, 4), oldW, marginH);

        g2d.drawString(font.getFontText().substring(1, 2), marginW, marginH);

        return bi;
    }

    /**
     * 绘制圆弧形文字
     *
     * @param g2d 画笔
     * @param circleRadius 弧形半径
     * @param font 字体对象
     * @param isTop 是否字体在上部,否则在下部
     */
    private static void drawArcFont4Circle(Graphics2D g2d, int circleRadius, SealFont font, boolean isTop) {
        if (font == null) {
            return;
        }

        //1.字体长度
        int fontTextLen = font.getFontText().length();

        //2.字体大小,默认根据字体长度动态设定 TODO
        int fontSize = font.getFontSize() == null ? (55 - fontTextLen * 2) : font.getFontSize();

        //3.字体样式
        int fontStyle = font.isBold() ? Font.BOLD : Font.PLAIN;

        //4.构造字体
        Font f = new Font(font.getFontFamily(), fontStyle, fontSize);

        FontRenderContext context = g2d.getFontRenderContext();
        Rectangle2D rectangle = f.getStringBounds(font.getFontText(), context);

        //5.文字之间间距,默认动态调整
        double fontSpace;
        if (font.getFontSpace() != null) {
            fontSpace = font.getFontSpace();
        } else {
            if (fontTextLen == 1) {
                fontSpace = 0;
            } else {
                fontSpace = rectangle.getWidth() / (fontTextLen - 1) * 0.9;
            }
        }

        //6.距离外圈距离
        int marginSize = font.getMarginSize() == null ? INIT_BEGIN : font.getMarginSize();

        //7.写字
        double newRadius = circleRadius + rectangle.getY() - marginSize;
        double radianPerInterval = 2 * Math.asin(fontSpace / (2 * newRadius));

        double fix = 0.04;
        if (isTop) {
            fix = 0.18;
        }
        double firstAngle;
        if (!isTop) {
            if (fontTextLen % 2 == 1) {
                firstAngle = Math.PI + Math.PI / 2 - (fontTextLen - 1) * radianPerInterval / 2.0 - fix;
            } else {
                firstAngle = Math.PI + Math.PI / 2 - ((fontTextLen / 2.0 - 0.5) * radianPerInterval) - fix;
            }
        } else {
            if (fontTextLen % 2 == 1) {
                firstAngle = (fontTextLen - 1) * radianPerInterval / 2.0 + Math.PI / 2 + fix;
            } else {
                firstAngle = (fontTextLen / 2.0 - 0.5) * radianPerInterval + Math.PI / 2 + fix;
            }
        }

        for (int i = 0; i < fontTextLen; i++) {
            double theta;
            double thetaX;
            double thetaY;

            if (!isTop) {
                theta = firstAngle + i * radianPerInterval;
                thetaX = newRadius * Math.sin(Math.PI / 2 - theta);
                thetaY = newRadius * Math.cos(theta - Math.PI / 2);
            } else {
                theta = firstAngle - i * radianPerInterval;
                thetaX = newRadius * Math.sin(Math.PI / 2 - theta);
                thetaY = newRadius * Math.cos(theta - Math.PI / 2);
            }

            AffineTransform transform;
            if (!isTop) {
                transform = AffineTransform.getRotateInstance(Math.PI + Math.PI / 2 - theta);
            } else {
                transform = AffineTransform.getRotateInstance(Math.PI / 2 - theta + Math.toRadians(8));
            }
            Font f2 = f.deriveFont(transform);
            g2d.setFont(f2);
            g2d.drawString(font.getFontText().substring(i, i + 1), (float) (circleRadius + thetaX + INIT_BEGIN),
                    (float) (circleRadius - thetaY + INIT_BEGIN));
        }
    }

    /**
     * 绘制椭圆弧形文字
     *
     * @param g2d 画笔
     * @param circle 外围圆
     * @param font 字体对象
     * @param isTop 是否字体在上部,否则在下部
     */
    private static void drawArcFont4Oval(Graphics2D g2d, SealCircle circle, SealFont font, boolean isTop) {
        if (font == null) {
            return;
        }
        float radiusX = circle.getWidth();
        float radiusY = circle.getHeight();
        float radiusWidth = radiusX + circle.getLineSize();
        float radiusHeight = radiusY + circle.getLineSize();

        //1.字体长度
        int fontTextLen = font.getFontText().length();

        //2.字体大小,默认根据字体长度动态设定
        int fontSize = font.getFontSize() == null ? 25 + (10 - fontTextLen) / 2 : font.getFontSize();

        //3.字体样式
        int fontStyle = font.isBold() ? Font.BOLD : Font.PLAIN;

        //4.构造字体
        Font f = new Font(font.getFontFamily(), fontStyle, fontSize);

        //5.总的角跨度
        float totalArcAng = (float) (font.getFontSpace() * fontTextLen);

        //6.从边线向中心的移动因子
        float minRat = 0.90f;

        double startAngle = isTop ? -90f - totalArcAng / 2f : 90f - totalArcAng / 2f;
        double step = 0.5;
        int alCount = (int) Math.ceil(totalArcAng / step) + 1;
        double[] angleArr = new double[alCount];
        double[] arcLenArr = new double[alCount];
        int num = 0;
        double accArcLen = 0.0;
        angleArr[num] = startAngle;
        arcLenArr[num] = accArcLen;
        num++;
        double angR = startAngle * Math.PI / 180.0;
        double lastX = radiusX * Math.cos(angR) + radiusWidth;
        double lastY = radiusY * Math.sin(angR) + radiusHeight;
        for (double i = startAngle + step; num < alCount; i += step) {
            angR = i * Math.PI / 180.0;
            double x = radiusX * Math.cos(angR) + radiusWidth, y = radiusY * Math.sin(angR) + radiusHeight;
            accArcLen += Math.sqrt((lastX - x) * (lastX - x) + (lastY - y) * (lastY - y));
            angleArr[num] = i;
            arcLenArr[num] = accArcLen;
            lastX = x;
            lastY = y;
            num++;
        }
        double arcPer = accArcLen / fontTextLen;
        for (int i = 0; i < fontTextLen; i++) {
            double arcL = i * arcPer + arcPer / 2.0;
            double ang = 0.0;
            for (int p = 0; p < arcLenArr.length - 1; p++) {
                if (arcLenArr[p] <= arcL && arcL <= arcLenArr[p + 1]) {
                    ang = (arcL >= ((arcLenArr[p] + arcLenArr[p + 1]) / 2.0)) ? angleArr[p + 1] : angleArr[p];
                    break;
                }
            }
            angR = (ang * Math.PI / 180f);
            Float x = radiusX * (float) Math.cos(angR) + radiusWidth;
            Float y = radiusY * (float) Math.sin(angR) + radiusHeight;
            double qxang = Math.atan2(radiusY * Math.cos(angR), -radiusX * Math.sin(angR));
            double fxang = qxang + Math.PI / 2.0;

            int subIndex = isTop ? i : fontTextLen - 1 - i;
            String c = font.getFontText().substring(subIndex, subIndex + 1);

            //获取文字高宽
            FontMetrics fm = new JLabel().getFontMetrics(f);
            int w = fm.stringWidth(c), h = fm.getHeight();

            if (isTop) {
                x += h * minRat * (float) Math.cos(fxang);
                y += h * minRat * (float) Math.sin(fxang);
                x += -w / 2f * (float) Math.cos(qxang);
                y += -w / 2f * (float) Math.sin(qxang);
            } else {
                x += (h * minRat ) * (float) Math.cos(fxang);
                y += (h * minRat) * (float) Math.sin(fxang);
                x += w / 2f * (float) Math.cos(qxang);
                y += w / 2f * (float) Math.sin(qxang);
            }

            // 旋转
            AffineTransform affineTransform = new AffineTransform();
            affineTransform.scale(0.8, 1);
            if (isTop) {
                affineTransform.rotate(Math.toRadians((fxang * 180.0 / Math.PI - 90)), 0, 0);
            } else {
                affineTransform.rotate(Math.toRadians((fxang * 180.0 / Math.PI + 180 - 90)), 0, 0);
            }
            Font f2 = f.deriveFont(affineTransform);
            g2d.setFont(f2);
            g2d.drawString(c, x.intValue() + INIT_BEGIN, y.intValue() + INIT_BEGIN);
        }
    }

    /**
     * 画文字
     *
     * @param g2d 画笔
     * @param circleWidth 边线圆宽度
     * @param circleHeight 边线圆高度
     * @param font 字体对象
     */
    private static void drawFont(Graphics2D g2d, int circleWidth, int circleHeight, SealFont font) {
        if (font == null) {
            return;
        }

        //1.字体长度
        int fontTextLen = font.getFontText().length();

        //2.字体大小,默认根据字体长度动态设定
        int fontSize = font.getFontSize() == null ? (55 - fontTextLen * 2) : font.getFontSize();

        //3.字体样式
        int fontStyle = font.isBold() ? Font.BOLD : Font.PLAIN;

        //4.构造字体
        Font f = new Font(font.getFontFamily(), fontStyle, fontSize);
        g2d.setFont(f);

        FontRenderContext context = g2d.getFontRenderContext();
        String[] fontTexts = font.getFontText().split("\n");
        if (fontTexts.length > 1) {
            int y = 0;
            for (String fontText : fontTexts) {
                y += Math.abs(f.getStringBounds(fontText, context).getHeight());
            }
            //5.设置上边距
            float marginSize = INIT_BEGIN + (float) (circleHeight / 2f - y / 2f);
            for (String fontText : fontTexts) {
                Rectangle2D rectangle2D = f.getStringBounds(fontText, context);
                g2d.drawString(fontText, (float) (circleWidth / 2f - rectangle2D.getCenterX() + 1f), marginSize);
                marginSize += Math.abs(rectangle2D.getHeight());
            }
        } else {
            Rectangle2D rectangle2D = f.getStringBounds(font.getFontText(), context);
            //5.设置上边距,默认在中心
            float marginSize = font.getMarginSize() == null ?
                    (float) (circleHeight / 2f - rectangle2D.getCenterY()) :
                    (float) (circleHeight / 2f - rectangle2D.getCenterY()) + (float) font.getMarginSize();
            g2d.drawString(font.getFontText(), (float) (circleWidth / 2f - rectangle2D.getCenterX() + 1), marginSize);
        }
    }

    /**
     * 画圆
     *
     * @param g2d 画笔
     * @param circle 圆配置对象
     */
    private static void drawCicle(Graphics2D g2d, SealCircle circle, int x, int y) {
        if (circle == null) {
            return;
        }

        //1.圆线条粗细默认是圆直径的1/35
        int lineSize = circle.getLineSize() == null ? circle.getHeight() * 2 / (35) : circle.getLineSize();

        //2.画圆
        g2d.setStroke(new BasicStroke(lineSize));
        g2d.drawOval(x, y, circle.getWidth() * 2, circle.getHeight() * 2);
    }


  /*  public static void main(String[] args) throws Exception {
        SealConfiguration sealConfiguration = new SealConfiguration();
        sealConfiguration.setMainFont(new SealFont("山东省投融资担保集团有限公司", true, "宋体", 38, 38d, 10))
                .setCenterFont(new SealFont("★", false, "宋体", 60, 60d, 0))
                .setTitleFont(new SealFont("电子签章", true, "宋体", 30, 30d, 40))
               *//* .setViceFont(new SealFont("3701027650943", false, "宋体", 69, 48d, 0))*//*
                .setBackgroudColor(Color.RED).setImageSize(320).setBorderCircle(new SealCircle(8, 150, 150));

        SealUtil.buildAndStoreSeal(sealConfiguration,"E:\\letter\\sign\\sign.png");
    }*/

}

印章配置类 SealConfiguration

import java.awt.*;

/**
 * @Description: 印章配置类
 * @Author
 * @Date:
 */
public class SealConfiguration {
    /**
     * 主文字
     */
    private SealFont mainFont;
    /**
     * 副文字
     */
    private SealFont viceFont;
    /**
     * 抬头文字
     */
    private SealFont titleFont;
    /**
     * 中心文字
     */
    private SealFont centerFont;
    /**
     * 边线圆
     */
    private SealCircle borderCircle;
    /**
     * 内边线圆
     */
    private SealCircle borderInnerCircle;
    /**
     * 内线圆
     */
    private SealCircle innerCircle;
    /**
     * 背景色,默认红色
     */
    private Color backgroudColor = Color.RED;
    /**
     * 图片输出尺寸,默认300
     */
    private Integer imageSize = 30;

    public SealConfiguration setMainFont(SealFont mainFont) {
        this.mainFont = mainFont;
        return this;
    }

    public SealConfiguration setViceFont(SealFont viceFont) {
        this.viceFont = viceFont;
        return this;
    }

    public SealConfiguration setTitleFont(SealFont titleFont) {
        this.titleFont = titleFont;
        return this;
    }

    public SealConfiguration setCenterFont(SealFont centerFont) {
        this.centerFont = centerFont;
        return this;
    }

    public SealConfiguration setBorderCircle(SealCircle borderCircle) {
        this.borderCircle = borderCircle;
        return this;
    }

    public SealConfiguration setBorderInnerCircle(SealCircle borderInnerCircle) {
        this.borderInnerCircle = borderInnerCircle;
        return this;
    }

    public SealConfiguration setInnerCircle(SealCircle innerCircle) {
        this.innerCircle = innerCircle;
        return this;
    }

    public SealConfiguration setBackgroudColor(Color backgroudColor) {
        this.backgroudColor = backgroudColor;
        return this;
    }

    public SealConfiguration setImageSize(Integer imageSize) {
        this.imageSize = imageSize;
        return this;
    }

    public SealFont getMainFont() {
        return mainFont;
    }

    public SealFont getViceFont() {
        return viceFont;
    }

    public SealFont getTitleFont() {
        return titleFont;
    }

    public SealFont getCenterFont() {
        return centerFont;
    }

    public SealCircle getBorderCircle() {
        return borderCircle;
    }

    public SealCircle getBorderInnerCircle() {
        return borderInnerCircle;
    }

    public SealCircle getInnerCircle() {
        return innerCircle;
    }

    public Color getBackgroudColor() {
        return backgroudColor;
    }

    public Integer getImageSize() {
        return imageSize;
    }
}

PdfUtil PDF工具类


import cn.hutool.core.util.NumberUtil;
import cn.hutool.json.JSONUtil;

import cn.hutool.core.util.NumberUtil;
import cn.hutool.json.JSONUtil;
import com.google.code.appengine.awt.Color;
import com.lowagie.text.Font;
import com.lowagie.text.pdf.BaseFont;
import com.ruoyi.common.utils.letter.sign.EPortDigitalSignInfo;
import com.ruoyi.common.utils.letter.sign.EPortDigitalSignPosition;
import com.ruoyi.common.utils.letter.sign.ElectronicSignUtil;
import com.sddbjt.boot.framework.common.enums.ErrorCodeEnum;
import com.sddbjt.boot.framework.common.exception.ServiceException;
import fr.opensagres.poi.xwpf.converter.pdf.PdfConverter;
import fr.opensagres.poi.xwpf.converter.pdf.PdfOptions;
import fr.opensagres.xdocreport.itext.extension.font.IFontProvider;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.xwpf.usermodel.XWPFDocument;

import java.io.*;
import java.util.Map;

/**
 * @description:
 */
@Slf4j
public class PdfUtil {

    public  static void signPdf(String sourcePdfPath , String signName, String signedPdfPath, String signPicPath,
                                InputStream certInputStream, String certPassword, String keyword,int page) {

        //获取签章位置
        Map<String,String> positionMap=ItextUtil.findKeywordPosition(sourcePdfPath,keyword,false);
        log.info("signPdf positionMap:{}", JSONUtil.toJsonStr(positionMap));
        double x= NumberUtil.parseDouble(positionMap.get("x"));
        double y= NumberUtil.parseDouble(positionMap.get("y"));
        int signPage = NumberUtil.parseInt(positionMap.get("page"));
        try(FileInputStream picInputStream= new FileInputStream(signPicPath)){
            //设置签章位置及缩放比例
            EPortDigitalSignPosition signPosition = new EPortDigitalSignPosition(x-20,y-60,signPage, -65);
            //设置签章属性信息
            EPortDigitalSignInfo signInfo = new EPortDigitalSignInfo(signName, null, null);
            ElectronicSignUtil.sign(certPassword, certInputStream,picInputStream, new File(sourcePdfPath),
                    new File(signedPdfPath),signInfo, signPosition);
        } catch (Exception e) {
            log.error("signPdf e:{}",e.getMessage(),e);
            throw new ServiceException(ErrorCodeEnum.SYSTEM_ERROR.getCode(),"pdf签章异常");
        }
    }

  /*  public static void main(String[] args) throws Exception {

        String sourcePdfPath ="E:\\letter\\sign\\b.pdf";
        String signName ="山东省投融资担保集团有限公司";
        String signedPdfPath="E:\\letter\\signed\\b.pdf";

        String signPicPath ="E:\\letter\\sign\\sign.png";
        String certPath ="E:\\letter\\cert\\sdph.pfx";
        String certPassword ="Sddb@@12pq##q";
        String keyword ="签章";

        signPdf(sourcePdfPath,signName,signedPdfPath,signPicPath,new FileInputStream(new File(certPath)),certPassword,keyword);
    }*/


    /***
     *@description:生成pdf文件
     *[org.apache.poi.xwpf.usermodel.XWPFDocument, java.io.OutputStream]
     *void
     *@author: zhaowenchao
     *@date: 2023/6/26 14:12
     *
     */
    public static void createPdf(XWPFDocument document , OutputStream pdfOutputStream){
        log.info("===createPdf begin");
        PdfOptions options = PdfOptions.create();
        options.fontProvider(new IFontProvider() {
            public Font getFont(String familyName, String encoding, float size, int style, Color color) {
                try {
                    BaseFont bfChinese = BaseFont.createFont("static/fonts/simsun.ttc,1", BaseFont.IDENTITY_H, BaseFont.NOT_EMBEDDED);
                    Font fontChinese = new Font(bfChinese, size, style, color);
                    if (familyName != null)
                        fontChinese.setFamily(familyName);
                    return fontChinese;
                } catch (Exception e) {
                    log.error("e:{}",e.getMessage(),e);
                    throw new RuntimeException();
                }
            }
        });
        try {
            PdfConverter.getInstance().convert(document, pdfOutputStream, options);
            log.info("===createPdf end");
        } catch (IOException e) {
            log.error("PdfUtils createPdf e :{}",e.getMessage(),e);
            throw new ServiceException(ErrorCodeEnum.SYSTEM_ERROR.getCode(),e.getMessage());
        }
    }
}

ItextUtil

import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.util.NumberUtil;
import cn.hutool.json.JSONUtil;
import com.itextpdf.awt.geom.Rectangle2D;
import com.itextpdf.text.DocumentException;
import com.itextpdf.text.Image;
import com.itextpdf.text.Rectangle;
import com.itextpdf.text.pdf.*;
import com.itextpdf.text.pdf.parser.*;
import com.ruoyi.common.exception.ServiceException;
import lombok.extern.slf4j.Slf4j;

import java.awt.geom.Arc2D;
import java.io.*;
import java.util.*;

/**
 * 
 */
@Slf4j
public class ItextUtil {

   /* public static void main(String[] args) throws IOException, DocumentException {
        String templatePath="E:\\letter\\sign\\b.pdf";
        String targetPath="E:\\letter\\sign\\c.pdf";
        String fieldName="签章";
        String imagePath="E:\\letter\\sign\\sign.png";
        signSealAtSignPosition(templatePath,targetPath,imagePath,fieldName);
    }*/

    /**
     * 在pdf文件签章关键字位置添加悬浮签章图片
     * @param pdfPath 原始pdf文件
     * @param targetPath 目标文件地址
     * @param imagePath 图片地址
     * @param keyword 关键字
     */
    public static void  signSealAtSignPosition(String pdfPath,String targetPath,String imagePath,String keyword){
        Map<String,String> positionMap=findKeywordPosition(pdfPath,keyword,true);
        log.info(" pdf sign position :{}", JSONUtil.toJsonStr(positionMap));
        signSeal(pdfPath,targetPath,imagePath, NumberUtil.parseFloat(positionMap.get("x")),NumberUtil.parseFloat(positionMap.get("y")));
    }

    /**
     * 在pdf指定位置添加悬浮图片
     * @param pdfPath
     * @param targetPath
     * @param imagePath
     * @param x
     * @param y
     */
    public static void signSeal(String pdfPath,String targetPath,String imagePath,float x, float y){
        log.info("=== signSeal  begin");
        try(InputStream  inputStream=new FileInputStream(new File(pdfPath));
            OutputStream outputStream= new FileOutputStream(new File(targetPath))) {
            PdfReader reader = new PdfReader(inputStream);
            PdfStamper stamper = new PdfStamper(reader,outputStream);
            Image image =Image.getInstance(imagePath);
            PdfContentByte under  =  stamper.getOverContent(1);
            image.scaleToFit(130,130);
            image.setAbsolutePosition(x-120,y-80);
            under.addImage(image);
            stamper.close();
            reader.close();
            log.info("=== signSeal  end");
        } catch (IOException e) {
            log.error("signSeal e:{}",e.getMessage(),e);
            throw new ServiceException("io异常");
        } catch (DocumentException e) {
            log.error("signSeal e:{}",e.getMessage(),e);
            throw new ServiceException("Document异常");
        }
    }


    /**
     * 获取pdf中关键字位置
     * @param pdfPath pdf文件路径
     * @param keyword 关键字
     * @param reverse 高度根据pdf高度反转
     * @return
     */
    public static Map<String,String> findKeywordPosition(String pdfPath,String keyword,boolean reverse){

        float  pdfHeight=getPdfHeight(pdfPath);

        try(FileInputStream pdfInputStream=new FileInputStream(new File(pdfPath))){
            Map<String, List<float[]>> map  = findKeywordPostions(pdfInputStream, Arrays.asList(keyword));
            if(CollectionUtil.isEmpty(map)){
                throw new ServiceException("关键字位置获取异常");
            }
            List<float[]> list = map.get(keyword);
            if(CollectionUtil.isEmpty(list)){
                throw new ServiceException("关键字位置集合中不包含指定关键字");
            }
            float[] position = list.get(0);
            float x=  position[1];
            float y=  position[2];
            float page = position[0];
            log.info("findKeywordPosition x:{},y:{},page:{}",x,y,page);
            Map<String,String>  positionMap=new HashMap();
            positionMap.put("page",String.valueOf(page));
            positionMap.put("x",String.valueOf(x));
            if(reverse){
                positionMap.put("y",String.valueOf(pdfHeight-y));
            }else{
                positionMap.put("y",String.valueOf(y));
            }

            return positionMap;
        } catch (Exception e) {
            log.error("findKeywordPosition e:{}",e.getMessage(),e);
            throw new ServiceException("findKeyword异常");
        }
    }


    /**
     * 获取pdf单页高度
     * @param pdfPath
     * @return
     */
    public static float getPdfHeight(String pdfPath){
        PdfReader reader=null;
        try {
            reader= new PdfReader(pdfPath);
        } catch (IOException e) {
            log.error("getPdfHeight IOException:{} ",e.getMessage(),e);
        }
        Rectangle rectangle =reader.getPageSizeWithRotation(1);
        log.debug("pdfPath:{} Width:",pdfPath,rectangle.getWidth());
        log.debug("pdfPath:{} Height:",pdfPath,rectangle.getHeight());
        reader.close();
        return  rectangle.getHeight();

    }


    /**
     * findKeywordPostions
     *
     * @param inputStream
     * @param keywords
     *            关键字
     * @return List<float [ ]> : float[0]:pageNum float[1]:x float[2]:y
     * @throws IOException
     */
    public static Map<String, List<float[]>> findKeywordPostions(InputStream inputStream, List<String> keywords) {

        List<PdfPageContentPositions> pdfPageContentPositions = null;
        try {
            pdfPageContentPositions = getPdfContentPostionsList(inputStream);
        } catch (IOException e) {
            log.error("findKeywordPostions e:{}",e.getMessage(),e);
            throw new ServiceException("关键字位置获取异常");
        }
        Map<String, List<float[]>> resultMap = new HashMap<>();
        if(CollectionUtil.isNotEmpty(pdfPageContentPositions)){
            for (String keyword : keywords) {
                List<float[]> result = new ArrayList<>();
                for (PdfPageContentPositions pdfPageContentPosition : pdfPageContentPositions) {
                    List<float[]> charPositions = findPositions(keyword, pdfPageContentPosition);
                    if (charPositions == null || charPositions.size() < 1) {
                        continue;
                    }
                    result.addAll(charPositions);
                }
                resultMap.put(keyword, result);
            }
        }
        return resultMap;
    }


    public static List<PdfPageContentPositions> getPdfContentPostionsList(InputStream inputStream) throws IOException {
        PdfReader reader = new PdfReader(inputStream);
        List<PdfPageContentPositions> result = new ArrayList<>();
        int pages = reader.getNumberOfPages();
        for (int pageNum = 1; pageNum <= pages; pageNum++) {
            float width = reader.getPageSize(pageNum).getWidth();
            float height = reader.getPageSize(pageNum).getHeight();

            PdfRenderListener pdfRenderListener = new PdfRenderListener(pageNum, width, height);

            // 解析pdf,定位位置
            PdfContentStreamProcessor processor = new PdfContentStreamProcessor(pdfRenderListener);
            PdfDictionary pageDic = reader.getPageN(pageNum);
            PdfDictionary resourcesDic = pageDic.getAsDict(PdfName.RESOURCES);
            try {
                processor.processContent(ContentByteUtils.getContentBytesForPage(reader, pageNum), resourcesDic);
            } catch (IOException e) {
                reader.close();
                throw e;
            }

            String content = pdfRenderListener.getContent();
            List<CharPosition> charPositions = pdfRenderListener.getcharPositions();

            List<float[]> positionsList = new ArrayList<>();
            for (CharPosition charPosition : charPositions) {
                float[] positions = new float[] { charPosition.getPageNum(), charPosition.getX(), charPosition.getY() };
                positionsList.add(positions);
            }

            PdfPageContentPositions pdfPageContentPositions = new PdfPageContentPositions();
            pdfPageContentPositions.setContent(content);
            pdfPageContentPositions.setPostions(positionsList);

            result.add(pdfPageContentPositions);
        }
        reader.close();
        return result;
    }

    private static List<float[]> findPositions(String keyword, PdfPageContentPositions pdfPageContentPositions) {

        List<float[]> result = new ArrayList<>();

        String content = pdfPageContentPositions.getContent();
        List<float[]> charPositions = pdfPageContentPositions.getPositions();

        for (int pos = 0; pos < content.length();) {
            int positionIndex = content.indexOf(keyword, pos);
            if (positionIndex == -1) {
                break;
            }
            float[] postions = charPositions.get(positionIndex);
            result.add(postions);
            pos = positionIndex + 1;
        }
        return result;
    }

    private static class PdfPageContentPositions {
        private String content;
        private List<float[]> positions;

        public String getContent() {
            return content;
        }

        public void setContent(String content) {
            this.content = content;
        }

        public List<float[]> getPositions() {
            return positions;
        }

        public void setPostions(List<float[]> positions) {
            this.positions = positions;
        }
    }

    private static class PdfRenderListener implements RenderListener {
        private int pageNum;
        private float pageWidth;
        private float pageHeight;
        private StringBuilder contentBuilder = new StringBuilder();
        private List<CharPosition> charPositions = new ArrayList<>();

        public PdfRenderListener(int pageNum, float pageWidth, float pageHeight) {
            this.pageNum = pageNum;
            this.pageWidth = pageWidth;
            this.pageHeight = pageHeight;
        }

        public void beginTextBlock() {
        }

        public void renderText(TextRenderInfo renderInfo) {
            List<TextRenderInfo> characterRenderInfos = renderInfo.getCharacterRenderInfos();
            for (TextRenderInfo textRenderInfo : characterRenderInfos) {
                String word = textRenderInfo.getText();
                if (word.length() > 1) {
                    word = word.substring(word.length() - 1, word.length());
                }
                Rectangle2D.Float rectangle = textRenderInfo.getAscentLine().getBoundingRectange();

                float x = (float) rectangle.getX();
                float y = pageHeight - (float) rectangle.getY();

                // 这两个是关键字在所在页面的XY轴的百分比
                float xPercent = Math.round(x / pageWidth * 10000) / 10000f;
                float yPercent = Math.round((1 - y / pageHeight) * 10000) / 10000f;

                CharPosition charPosition = new CharPosition(pageNum, (float) x, (float) y);
                charPositions.add(charPosition);
                contentBuilder.append(word);
            }
        }

        public void endTextBlock() {
        }

        public void renderImage(ImageRenderInfo renderInfo) {
        }

        public String getContent() {
            return contentBuilder.toString();
        }

        public List<CharPosition> getcharPositions() {
            return charPositions;
        }
    }

    private static class CharPosition {
        private int pageNum = 0;
        private float x = 0;
        private float y = 0;

        public CharPosition(int pageNum, float x, float y) {
            this.pageNum = pageNum;
            this.x = x;
            this.y = y;
        }

        public int getPageNum() {
            return pageNum;
        }

        public float getX() {
            return x;
        }

        public float getY() {
            return y;
        }

        @Override
        public String toString() {
            return "[pageNum=" + this.pageNum + ",x=" + this.x + ",y=" + this.y + "]";
        }
    }


}

3.印章相关工具类

SealCircle

import lombok.Getter;
import lombok.Setter;

/**
 * @Description: 印章圆圈类
 * @Author
 * @Date:
 */
@Getter
@Setter
public class SealCircle {

    public SealCircle(Integer lineSize, Integer width,Integer height) {
        this.lineSize = lineSize;
        this.width = width;
        this.height = height;
    }
    public SealCircle(){}

    /**
     * 线宽度
     */
    private Integer lineSize;
    /**
     * 半径
     */
    private Integer width;
    /**
     * 半径
     */
    private Integer height;

    public Integer getLineSize() {
        return lineSize;
    }

    public Integer getHeight() {
        return height;
    }

    public Integer getWidth() {
        return width;
    }
}

SealConfiguration 印章配置类

import java.awt.*;

/**
 * @Description: 印章配置类
 * @Author
 * @Date:
 */
public class SealConfiguration {
    /**
     * 主文字
     */
    private SealFont mainFont;
    /**
     * 副文字
     */
    private SealFont viceFont;
    /**
     * 抬头文字
     */
    private SealFont titleFont;
    /**
     * 中心文字
     */
    private SealFont centerFont;
    /**
     * 边线圆
     */
    private SealCircle borderCircle;
    /**
     * 内边线圆
     */
    private SealCircle borderInnerCircle;
    /**
     * 内线圆
     */
    private SealCircle innerCircle;
    /**
     * 背景色,默认红色
     */
    private Color backgroudColor = Color.RED;
    /**
     * 图片输出尺寸,默认300
     */
    private Integer imageSize = 30;

    public SealConfiguration setMainFont(SealFont mainFont) {
        this.mainFont = mainFont;
        return this;
    }

    public SealConfiguration setViceFont(SealFont viceFont) {
        this.viceFont = viceFont;
        return this;
    }

    public SealConfiguration setTitleFont(SealFont titleFont) {
        this.titleFont = titleFont;
        return this;
    }

    public SealConfiguration setCenterFont(SealFont centerFont) {
        this.centerFont = centerFont;
        return this;
    }

    public SealConfiguration setBorderCircle(SealCircle borderCircle) {
        this.borderCircle = borderCircle;
        return this;
    }

    public SealConfiguration setBorderInnerCircle(SealCircle borderInnerCircle) {
        this.borderInnerCircle = borderInnerCircle;
        return this;
    }

    public SealConfiguration setInnerCircle(SealCircle innerCircle) {
        this.innerCircle = innerCircle;
        return this;
    }

    public SealConfiguration setBackgroudColor(Color backgroudColor) {
        this.backgroudColor = backgroudColor;
        return this;
    }

    public SealConfiguration setImageSize(Integer imageSize) {
        this.imageSize = imageSize;
        return this;
    }

    public SealFont getMainFont() {
        return mainFont;
    }

    public SealFont getViceFont() {
        return viceFont;
    }

    public SealFont getTitleFont() {
        return titleFont;
    }

    public SealFont getCenterFont() {
        return centerFont;
    }

    public SealCircle getBorderCircle() {
        return borderCircle;
    }

    public SealCircle getBorderInnerCircle() {
        return borderInnerCircle;
    }

    public SealCircle getInnerCircle() {
        return innerCircle;
    }

    public Color getBackgroudColor() {
        return backgroudColor;
    }

    public Integer getImageSize() {
        return imageSize;
    }
}

SealFont 印章字体类

import java.awt.*;

/**
 * @Description: 印章字体类
 * @Author
 * @Date:
 */
public class SealFont {

    public SealFont(String fontText, Boolean isBold, String fontFamily, Integer fontSize, Double fontSpace, Integer marginSize) {
        this.fontText = fontText;
        this.isBold = isBold;
        this.fontFamily = fontFamily;
        this.fontSize = fontSize;
        this.fontSpace = fontSpace;
        this.marginSize = marginSize;
    }

    public SealFont() {
    }

    /**
     * 字体内容
     */
    private String fontText;
    /**
     * 是否加粗
     */
    private Boolean isBold = true;
    /**
     * 字形名,默认为宋体
     */
    private String fontFamily = "宋体";
    /**
     * 字体大小
     */
    private Integer fontSize;
    /**
     * 字距
     */
    private Double fontSpace;
    /**
     * 边距(环边距或上边距)
     */
    private Integer marginSize;

    /**
     * 获取系统支持的字形名集合
     */
    public static String[] getSupportFontNames() {
        return GraphicsEnvironment.getLocalGraphicsEnvironment().getAvailableFontFamilyNames();
    }

    public SealFont setFontSpace(Double fontSpace) {
        this.fontSpace = fontSpace;
        return this;
    }

    public SealFont setMarginSize(Integer marginSize) {
        this.marginSize = marginSize;
        return this;
    }

    public SealFont setFontFamily(String fontFamily) {
        this.fontFamily = fontFamily;
        return this;
    }

    public SealFont setFontText(String fontText) {
        this.fontText = fontText;
        return this;
    }

    public SealFont setFontSize(Integer fontSize) {
        this.fontSize = fontSize;
        return this;
    }

    public SealFont setBold(Boolean bold) {
        isBold = bold;
        return this;
    }

    public String getFontText() {
        return fontText;
    }

    public String getFontFamily() {
        return fontFamily;
    }

    public Integer getFontSize() {
        return fontSize;
    }

    public Double getFontSpace() {
        return fontSpace;
    }

    public Integer getMarginSize() {
        return marginSize;
    }

    public Boolean isBold() {
        return isBold;
    }
}

4.签章相关工作类

CMSProcessableInputStream

import org.apache.pdfbox.io.IOUtils;
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
import org.bouncycastle.asn1.cms.CMSObjectIdentifiers;
import org.bouncycastle.cms.CMSException;
import org.bouncycastle.cms.CMSTypedData;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;

/**
 * Wraps a InputStream into a CMSProcessable object for bouncy castle. It's a memory saving
 * alternative to the {@link org.bouncycastle.cms.CMSProcessableByteArray CMSProcessableByteArray}
 * class.
 *
 * @author Thomas Chojecki
 */
class CMSProcessableInputStream implements CMSTypedData
{
    private InputStream in;
    private final ASN1ObjectIdentifier contentType;

    CMSProcessableInputStream(InputStream is)
    {
        this(new ASN1ObjectIdentifier(CMSObjectIdentifiers.data.getId()), is);
    }

    CMSProcessableInputStream(ASN1ObjectIdentifier type, InputStream is)
    {
        contentType = type;
        in = is;
    }

    @Override
    public Object getContent()
    {
        return in;
    }

    @Override
    public void write(OutputStream out) throws IOException, CMSException
    {
        // read the content only one time
        IOUtils.copy(in, out);
        in.close();
    }

    @Override
    public ASN1ObjectIdentifier getContentType()
    {
        return contentType;
    }
}

CreateSignature



import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.ExternalSigningSupport;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.PDSignature;

import java.io.*;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.UnrecoverableKeyException;
import java.security.cert.CertificateException;
import java.util.Calendar;

/**
 * An example for singing a PDF with bouncy castle.
 * A keystore can be created with the java keytool, for example:
 *
 * {@code keytool -genkeypair -storepass 123456 -storetype pkcs12 -alias test -validity 365
 *        -v -keyalg RSA -keystore keystore.p12 }
 *
 * @author Thomas Chojecki
 * @author Vakhtang Koroghlishvili
 * @author John Hewson
 */
public class CreateSignature extends CreateSignatureBase
{

    /**
     * Initialize the signature creator with a keystore and certficate password.
     *
     * @param keystore the pkcs12 keystore containing the signing certificate
     * @param pin the password for recovering the key
     * @throws KeyStoreException if the keystore has not been initialized (loaded)
     * @throws NoSuchAlgorithmException if the algorithm for recovering the key cannot be found
     * @throws UnrecoverableKeyException if the given password is wrong
     * @throws CertificateException if the certificate is not valid as signing time
     * @throws IOException if no certificate could be found
     */
    public CreateSignature(KeyStore keystore, char[] pin)
            throws KeyStoreException, UnrecoverableKeyException, NoSuchAlgorithmException, CertificateException, IOException
    {
        super(keystore, pin);
    }

    /**
     * Signs the given PDF file. Alters the original file on disk.
     * @param file the PDF file to sign
     * @throws IOException if the file could not be read or written
     */
    public void signDetached(File file) throws IOException
    {
        signDetached(file, file, null);
    }

    /**
     * Signs the given PDF file.
     * @param inFile input PDF file
     * @param outFile output PDF file
     * @throws IOException if the input file could not be read
     */
    public void signDetached(File inFile, File outFile) throws IOException
    {
        signDetached(inFile, outFile, null);
    }

    /**
     * Signs the given PDF file.
     * @param inFile input PDF file
     * @param outFile output PDF file
     * @param tsaClient optional TSA client
     * @throws IOException if the input file could not be read
     */
    public void signDetached(File inFile, File outFile, TSAClient tsaClient) throws IOException
    {
        if (inFile == null || !inFile.exists())
        {
            throw new FileNotFoundException("Document for signing does not exist");
        }

        FileOutputStream fos = new FileOutputStream(outFile);

        // sign
        try (PDDocument doc = PDDocument.load(inFile))
        {
            signDetached(doc, fos, tsaClient);
        }
    }

    public void signDetached(PDDocument document, OutputStream output, TSAClient tsaClient)
            throws IOException
    {
        setTsaClient(tsaClient);

        int accessPermissions = getMDPPermission(document);
        if (accessPermissions == 1)
        {
            throw new IllegalStateException("No changes to the document are permitted due to DocMDP transform parameters dictionary");
        }

        // create signature dictionary
        PDSignature signature = new PDSignature();
        signature.setFilter(PDSignature.FILTER_ADOBE_PPKLITE);
        signature.setSubFilter(PDSignature.SUBFILTER_ADBE_PKCS7_DETACHED);
        signature.setName("Example User");
        signature.setLocation("Los Angeles, CA");
        signature.setReason("Testing");

        // the signing date, needed for valid signature
        signature.setSignDate(Calendar.getInstance());

        // Optional: certify
        if (accessPermissions == 0)
        {
            setMDPPermission(document, signature, 2);
        }

        if (isExternalSigning())
        {
            System.out.println("Sign externally...");
            document.addSignature(signature);
            ExternalSigningSupport externalSigning =
                    document.saveIncrementalForExternalSigning(output);
            // invoke external signature service
            byte[] cmsSignature = sign(externalSigning.getContent());
            // set signature bytes received from the service
            externalSigning.setSignature(cmsSignature);
        }
        else
        {
            // register signature dictionary and sign interface
            document.addSignature(signature, this);

            // write incremental (only for signing purpose)
            document.saveIncremental(output);
        }
    }

    /*public static void main(String[] args) throws IOException, GeneralSecurityException
    {
        if (args.length < 3)
        {
            usage();
            System.exit(1);
        }

        String tsaUrl = null;
        boolean externalSig = false;
        for (int i = 0; i < args.length; i++)
        {
            if (args[i].equals("-tsa"))
            {
                i++;
                if (i >= args.length)
                {
                    usage();
                    System.exit(1);
                }
                tsaUrl = args[i];
            }
            if (args[i].equals("-e"))
            {
                externalSig = true;
            }
        }

        // load the keystore
        KeyStore keystore = KeyStore.getInstance("PKCS12");
        char[] password = args[1].toCharArray();
        keystore.load(new FileInputStream(args[0]), password);

        // TSA client
        TSAClient tsaClient = null;
        if (tsaUrl != null)
        {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            tsaClient = new TSAClient(new URL(tsaUrl), null, null, digest);
        }

        // sign PDF
        CreateSignature signing = new CreateSignature(keystore, password);
        signing.setExternalSigning(externalSig);

        File inFile = new File(args[2]);
        String name = inFile.getName();
        String substring = name.substring(0, name.lastIndexOf('.'));

        File outFile = new File(inFile.getParent(), substring + "_signed.pdf");
        signing.signDetached(inFile, outFile, tsaClient);
    }*/

    private static void usage()
    {
        System.err.println("usage: java " + CreateSignature.class.getName() + " " +
                           "<pkcs12_keystore> <password> <pdf_to_sign>\n" + "" +
                           "options:\n" +
                           "  -tsa <url>    sign timestamp using the given TSA server\n" +
                           "  -e            sign using external signature creation scenario");
    }
}

CreateSignatureBase

import org.apache.pdfbox.cos.COSArray;
import org.apache.pdfbox.cos.COSBase;
import org.apache.pdfbox.cos.COSDictionary;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.PDSignature;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.SignatureInterface;
import org.bouncycastle.asn1.*;
import org.bouncycastle.asn1.cms.Attribute;
import org.bouncycastle.asn1.cms.AttributeTable;
import org.bouncycastle.asn1.cms.Attributes;
import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers;
import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.cert.jcajce.JcaCertStore;
import org.bouncycastle.cms.*;
import org.bouncycastle.cms.jcajce.JcaSignerInfoGeneratorBuilder;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.OperatorCreationException;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder;
import org.bouncycastle.tsp.TSPException;
import org.bouncycastle.util.Store;

import java.io.IOException;
import java.io.InputStream;
import java.security.*;
import java.security.cert.Certificate;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Enumeration;
import java.util.List;

public abstract class CreateSignatureBase implements SignatureInterface
{
    private PrivateKey privateKey;
    private Certificate[] certificateChain;
    private TSAClient tsaClient;
    private boolean externalSigning;

    /**
     * Initialize the signature creator with a keystore (pkcs12) and pin that should be used for the
     * signature.
     *
     * @param keystore is a pkcs12 keystore.
     * @param pin is the pin for the keystore / private key
     * @throws KeyStoreException if the keystore has not been initialized (loaded)
     * @throws NoSuchAlgorithmException if the algorithm for recovering the key cannot be found
     * @throws UnrecoverableKeyException if the given password is wrong
     * @throws CertificateException if the certificate is not valid as signing time
     * @throws IOException if no certificate could be found
     */
    public CreateSignatureBase(KeyStore keystore, char[] pin)
            throws KeyStoreException, UnrecoverableKeyException, NoSuchAlgorithmException, IOException, CertificateException
    {
        // grabs the first alias from the keystore and get the private key. An
        // alternative method or constructor could be used for setting a specific
        // alias that should be used.
        Enumeration<String> aliases = keystore.aliases();
        String alias;
        Certificate cert = null;
        while (aliases.hasMoreElements())
        {
            alias = aliases.nextElement();
            setPrivateKey((PrivateKey) keystore.getKey(alias, pin));
            Certificate[] certChain = keystore.getCertificateChain(alias);
            if (certChain == null)
            {
                continue;
            }
            setCertificateChain(certChain);
            cert = certChain[0];
            if (cert instanceof X509Certificate)
            {
                // avoid expired certificate
                ((X509Certificate) cert).checkValidity();
            }
            break;
        }

        if (cert == null)
        {
            throw new IOException("Could not find certificate");
        }
    }

    public final void setPrivateKey(PrivateKey privateKey)
    {
        this.privateKey = privateKey;
    }

    public final void setCertificateChain(final Certificate[] certificateChain)
    {
        this.certificateChain = certificateChain;
    }

    public void setTsaClient(TSAClient tsaClient)
    {
        this.tsaClient = tsaClient;
    }

    public TSAClient getTsaClient()
    {
        return tsaClient;
    }

    /**
     * We just extend CMS signed Data
     *
     * @param signedData Generated CMS signed data
     * @return CMSSignedData Extended CMS signed data
     * @throws IOException
     * @throws TSPException
     */
    private CMSSignedData signTimeStamps(CMSSignedData signedData)
            throws IOException, TSPException
    {
        SignerInformationStore signerStore = signedData.getSignerInfos();
        List<SignerInformation> newSigners = new ArrayList<>();

        for (SignerInformation signer : signerStore.getSigners())
        {
            newSigners.add(signTimeStamp(signer));
        }

        return CMSSignedData.replaceSigners(signedData, new SignerInformationStore(newSigners));
    }

    /**
     * We are extending CMS Signature
     *
     * @param signer information about signer
     * @return information about SignerInformation
     */
    private SignerInformation signTimeStamp(SignerInformation signer)
            throws IOException, TSPException
    {
        AttributeTable unsignedAttributes = signer.getUnsignedAttributes();

        ASN1EncodableVector vector = new ASN1EncodableVector();
        if (unsignedAttributes != null)
        {
            vector = unsignedAttributes.toASN1EncodableVector();
        }

        byte[] token = getTsaClient().getTimeStampToken(signer.getSignature());
        ASN1ObjectIdentifier oid = PKCSObjectIdentifiers.id_aa_signatureTimeStampToken;
        ASN1Encodable signatureTimeStamp = new Attribute(oid, new DERSet(ASN1Primitive.fromByteArray(token)));

        vector.add(signatureTimeStamp);
        Attributes signedAttributes = new Attributes(vector);

        SignerInformation newSigner = SignerInformation.replaceUnsignedAttributes(
                signer, new AttributeTable(signedAttributes));

        if (newSigner == null)
        {
            return signer;
        }

        return newSigner;
    }

    /**
     * SignatureInterface implementation.
     *
     * This method will be called from inside of the pdfbox and create the PKCS #7 signature.
     * The given InputStream contains the bytes that are given by the byte range.
     *
     * This method is for internal use only.
     *
     * Use your favorite cryptographic library to implement PKCS #7 signature creation.
     *
     * @throws IOException
     */
    @Override
    public byte[] sign(InputStream content) throws IOException
    {
        try
        {
            List<Certificate> certList = new ArrayList<>();
            certList.addAll(Arrays.asList(certificateChain));
            Store certs = new JcaCertStore(certList);
            CMSSignedDataGenerator gen = new CMSSignedDataGenerator();
            org.bouncycastle.asn1.x509.Certificate cert = org.bouncycastle.asn1.x509.Certificate.getInstance(certificateChain[0].getEncoded());
            ContentSigner sha1Signer = new JcaContentSignerBuilder("SHA256WithRSA").build(privateKey);
            gen.addSignerInfoGenerator(new JcaSignerInfoGeneratorBuilder(new JcaDigestCalculatorProviderBuilder().build()).build(sha1Signer, new X509CertificateHolder(cert)));
            gen.addCertificates(certs);
            CMSProcessableInputStream msg = new CMSProcessableInputStream(content);
            CMSSignedData signedData = gen.generate(msg, false);
            if (tsaClient != null)
            {
                signedData = signTimeStamps(signedData);
            }
            return signedData.getEncoded();
        }
        catch (GeneralSecurityException | CMSException | TSPException | OperatorCreationException e)
        {
            throw new IOException(e);
        }
    }

    /**
     * Set if external signing scenario should be used.
     * If {@code false}, SignatureInterface would be used for signing.
     * <p>
     *     Default: {@code false}
     * </p>
     * @param externalSigning {@code true} if external signing should be performed
     */
    public void setExternalSigning(boolean externalSigning)
    {
        this.externalSigning = externalSigning;
    }

    public boolean isExternalSigning()
    {
        return externalSigning;
    }

    /**
     * Get the access permissions granted for this document in the DocMDP transform parameters
     * dictionary. Details are described in the table "Entries in the DocMDP transform parameters
     * dictionary" in the PDF specification.
     *
     * @param doc document.
     * @return the permission value. 0 means no DocMDP transform parameters dictionary exists. Other
     * return values are 1, 2 or 3. 2 is also returned if the DocMDP transform parameters dictionary
     * is found but did not contain a /P entry, or if the value is outside the valid range.
     */
    public int getMDPPermission(PDDocument doc)
    {
        COSBase base = doc.getDocumentCatalog().getCOSObject().getDictionaryObject(COSName.PERMS);
        if (base instanceof COSDictionary)
        {
            COSDictionary permsDict = (COSDictionary) base;
            base = permsDict.getDictionaryObject(COSName.DOCMDP);
            if (base instanceof COSDictionary)
            {
                COSDictionary signatureDict = (COSDictionary) base;
                base = signatureDict.getDictionaryObject("Reference");
                if (base instanceof COSArray)
                {
                    COSArray refArray = (COSArray) base;
                    for (int i = 0; i < refArray.size(); ++i)
                    {
                        base = refArray.getObject(i);
                        if (base instanceof COSDictionary)
                        {
                            COSDictionary sigRefDict = (COSDictionary) base;
                            if (COSName.DOCMDP.equals(sigRefDict.getDictionaryObject("TransformMethod")))
                            {
                                base = sigRefDict.getDictionaryObject("TransformParams");
                                if (base instanceof COSDictionary)
                                {
                                    COSDictionary transformDict = (COSDictionary) base;
                                    int accessPermissions = transformDict.getInt(COSName.P, 2);
                                    if (accessPermissions < 1 || accessPermissions > 3)
                                    {
                                        accessPermissions = 2;
                                    }
                                    return accessPermissions;
                                }
                            }
                        }
                    }
                }
            }
        }
        return 0;
    }

    public void setMDPPermission(PDDocument doc, PDSignature signature, int accessPermissions)
    {
        COSDictionary sigDict = signature.getCOSObject();

        // DocMDP specific stuff
        COSDictionary transformParameters = new COSDictionary();
        transformParameters.setItem(COSName.TYPE, COSName.getPDFName("TransformParams"));
        transformParameters.setInt(COSName.P, accessPermissions);
        transformParameters.setName(COSName.V, "1.2");
        transformParameters.setNeedToBeUpdated(true);

        COSDictionary referenceDict = new COSDictionary();
        referenceDict.setItem(COSName.TYPE, COSName.getPDFName("SigRef"));
        referenceDict.setItem("TransformMethod", COSName.getPDFName("DocMDP"));
        referenceDict.setItem("DigestMethod", COSName.getPDFName("SHA1"));
        referenceDict.setItem("TransformParams", transformParameters);
        referenceDict.setNeedToBeUpdated(true);

        COSArray referenceArray = new COSArray();
        referenceArray.add(referenceDict);
        sigDict.setItem("Reference", referenceArray);
        referenceArray.setNeedToBeUpdated(true);

        // Catalog
        COSDictionary catalogDict = doc.getDocumentCatalog().getCOSObject();
        COSDictionary permsDict = new COSDictionary();
        catalogDict.setItem(COSName.PERMS, permsDict);
        permsDict.setItem(COSName.DOCMDP, signature);
        catalogDict.setNeedToBeUpdated(true);
        permsDict.setNeedToBeUpdated(true);
    }
}

ElectronicSignUtil


import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.io.IOUtils;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.ExternalSigningSupport;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.PDSignature;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.SignatureInterface;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.SignatureOptions;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.visible.PDVisibleSigProperties;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.visible.PDVisibleSignDesigner;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
import org.apache.pdfbox.util.Hex;

import java.io.*;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.UnrecoverableKeyException;
import java.security.cert.CertificateException;
import java.util.Calendar;

public class ElectronicSignUtil extends CreateSignatureBase {
    private PDVisibleSignDesigner visibleSignDesigner;
    private final PDVisibleSigProperties visibleSignatureProperties = new PDVisibleSigProperties();
    private SignatureOptions signatureOptions;
    private boolean lateExternalSigning = false;

    /**
     * Initialize the signature creator with a keystore (pkcs12) and pin that
     * should be used for the signature.
     *
     * @param keystore is a pkcs12 keystore.
     * @param pin is the pin for the keystore / private key
     * @throws KeyStoreException if the keystore has not been initialized (loaded)
     * @throws NoSuchAlgorithmException if the algorithm for recovering the key cannot be found
     * @throws UnrecoverableKeyException if the given password is wrong
     * @throws CertificateException if the certificate is not valid as signing time
     * @throws IOException if no certificate could be found
     */

    public ElectronicSignUtil(KeyStore keystore, char[] pin)
            throws KeyStoreException, UnrecoverableKeyException, NoSuchAlgorithmException, IOException, CertificateException
    {
        super(keystore, pin);
    }

    /**
     * 对指定pdf电子签章,并保存到指定位置
     * @param password
     * @param p12Input
     * @param imageStream 由调用方进行流关闭处理
     * @param srcPdf
     * @param signed
     * @param ePortDigitalSignInfo
     * @param ePortDigitalSignPosition
     * @throws Exception
     */
    public static void sign(String password, InputStream p12Input, FileInputStream imageStream, File srcPdf, File signed,
                            EPortDigitalSignInfo ePortDigitalSignInfo, EPortDigitalSignPosition ePortDigitalSignPosition) throws Exception{
        boolean externalSig=false;
        KeyStore keystore = KeyStore.getInstance("PKCS12");
        keystore.load(p12Input, password.toCharArray());
        ElectronicSignUtil signing = new ElectronicSignUtil(keystore, password.toCharArray());
        int page = ePortDigitalSignPosition.getPageNo();
        signing.setVisibleSignDesigner(srcPdf.toString(), ePortDigitalSignPosition.getX(),
                ePortDigitalSignPosition.getY(),
                ePortDigitalSignPosition.getZoomPercent(), imageStream, page);
        signing.setVisibleSignatureProperties(ePortDigitalSignInfo.getSignerName(),
                ePortDigitalSignInfo.getCertLocation(), ePortDigitalSignInfo.getReason(),
                page, page, true);
        signing.setExternalSigning(externalSig);
        signing.signPDF(srcPdf, signed, null);
    }

    /**
     * Set visible signature designer for a new signature field.
     *
     * @param filename
     * @param x position of the signature field
     * @param y position of the signature field
     * @param zoomPercent
     * @param imageStream
     * @param page the signature should be placed on
     * @throws IOException
     */
    public void setVisibleSignDesigner(String filename, Double x, Double y, int zoomPercent,
                                       FileInputStream imageStream, int page)
            throws IOException
    {
        visibleSignDesigner = new PDVisibleSignDesigner(filename, imageStream, page);
        visibleSignDesigner.xAxis(x.floatValue()).yAxis(y.floatValue()).zoom(zoomPercent).adjustForRotation();
    }

    /**
     * Set visible signature properties for new signature fields.
     *
     * @param name
     * @param location
     * @param reason
     * @param preferredSize
     * @param page
     * @param visualSignEnabled
     * @throws IOException
     */
    public void setVisibleSignatureProperties(String name, String location, String reason, int preferredSize,
                                              int page, boolean visualSignEnabled) throws IOException
    {
        visibleSignatureProperties.signerName(name).signerLocation(location).signatureReason(reason).
                preferredSize(preferredSize).page(page).visualSignEnabled(visualSignEnabled).
                setPdVisibleSignature(visibleSignDesigner);
    }

    /**
     * Sign pdf file and create new file that ends with "_signed.pdf".
     *
     * @param inputFile The source pdf document file.
     * @param signedFile The file to be signed.
     * @param tsaClient optional TSA client
     * @throws IOException
     */
    public void signPDF(File inputFile, File signedFile, TSAClient tsaClient) throws IOException
    {
        this.signPDF(inputFile, signedFile, tsaClient, null);
    }

    /**
     * Sign pdf file and create new file that ends with "_signed.pdf".
     *
     * @param inputFile The source pdf document file.
     * @param signedFile The file to be signed.
     * @param tsaClient optional TSA client
     * @param signatureFieldName optional name of an existing (unsigned) signature field
     * @throws IOException
     */
    public void signPDF(File inputFile, File signedFile, TSAClient tsaClient, String signatureFieldName) throws IOException
    {
        setTsaClient(tsaClient);

        if (inputFile == null || !inputFile.exists())
        {
            throw new IOException("Document for signing does not exist");
        }

        // creating output document and prepare the IO streams.


        try (FileOutputStream fos = new FileOutputStream(signedFile);
             PDDocument doc = PDDocument.load(inputFile)){

            int accessPermissions = getMDPPermission(doc);
            if (accessPermissions == 1)
            {
                throw new IllegalStateException("No changes to the document are permitted due to DocMDP transform parameters dictionary");
            }
            // Note that PDFBox has a bug that visual signing on certified files with permission 2
            // doesn't work properly, see PDFBOX-3699. As long as this issue is open, you may want to
            // be careful with such files.

            PDSignature signature;

            // sign a PDF with an existing empty signature, as created by the CreateEmptySignatureForm example.
            signature = findExistingSignature(doc, signatureFieldName);

            if (signature == null)
            {
                // create signature dictionary
                signature = new PDSignature();
            }

            // Optional: certify
            // can be done only if version is at least 1.5 and if not already set
            // doing this on a PDF/A-1b file fails validation by Adobe preflight (PDFBOX-3821)
            // PDF/A-1b requires PDF version 1.4 max, so don't increase the version on such files.
            if (doc.getVersion() >= 1.5f && accessPermissions == 0)
            {
                setMDPPermission(doc, signature, 2);
            }

            PDAcroForm acroForm = doc.getDocumentCatalog().getAcroForm();
            if (acroForm != null && acroForm.getNeedAppearances())
            {
                // PDFBOX-3738 NeedAppearances true results in visible signature becoming invisible
                // with Adobe Reader
                if (acroForm.getFields().isEmpty())
                {
                    // we can safely delete it if there are no fields
                    acroForm.getCOSObject().removeItem(COSName.NEED_APPEARANCES);
                    // note that if you've set MDP permissions, the removal of this item
                    // may result in Adobe Reader claiming that the document has been changed.
                    // and/or that field content won't be displayed properly.
                    // ==> decide what you prefer and adjust your code accordingly.
                }
                else
                {
                    System.out.println("/NeedAppearances is set, signature may be ignored by Adobe Reader");
                }
            }

            // default filter
            signature.setFilter(PDSignature.FILTER_ADOBE_PPKLITE);

            // subfilter for basic and PAdES Part 2 signatures
            signature.setSubFilter(PDSignature.SUBFILTER_ADBE_PKCS7_DETACHED);

            if (visibleSignatureProperties != null)
            {
                // this builds the signature structures in a separate document
                visibleSignatureProperties.buildSignature();

                signature.setName(visibleSignatureProperties.getSignerName());
                signature.setLocation(visibleSignatureProperties.getSignerLocation());
                signature.setReason(visibleSignatureProperties.getSignatureReason());
            }

            // the signing date, needed for valid signature
            signature.setSignDate(Calendar.getInstance());

            // do not set SignatureInterface instance, if external signing used
            SignatureInterface signatureInterface = isExternalSigning() ? null : this;

            // register signature dictionary and sign interface
            if (visibleSignatureProperties != null && visibleSignatureProperties.isVisualSignEnabled())
            {
                signatureOptions = new SignatureOptions();
                signatureOptions.setVisualSignature(visibleSignatureProperties.getVisibleSignature());
                signatureOptions.setPage(visibleSignatureProperties.getPage() - 1);
                doc.addSignature(signature, signatureInterface, signatureOptions);
            }
            else
            {
                doc.addSignature(signature, signatureInterface);
            }

            if (isExternalSigning())
            {
                System.out.println("Signing externally " + signedFile.getName());
                ExternalSigningSupport externalSigning = doc.saveIncrementalForExternalSigning(fos);
                // invoke external signature service
                byte[] cmsSignature = sign(externalSigning.getContent());

                // Explanation of late external signing (off by default):
                // If you want to add the signature in a separate step, then set an empty byte array
                // and call signature.getByteRange() and remember the offset signature.getByteRange()[1]+1.
                // you can write the ascii hex signature at a later time even if you don't have this
                // PDDocument object anymore, with classic java file random access methods.
                // If you can't remember the offset value from ByteRange because your context has changed,
                // then open the file with PDFBox, find the field with findExistingSignature() or
                // PODDocument.getLastSignatureDictionary() and get the ByteRange from there.
                // Close the file and then write the signature as explained earlier in this comment.
                if (isLateExternalSigning())
                {
                    // this saves the file with a 0 signature
                    externalSigning.setSignature(new byte[0]);

                    // remember the offset (add 1 because of "<")
                    int offset = signature.getByteRange()[1] + 1;

                    // now write the signature at the correct offset without any PDFBox methods
                    try (RandomAccessFile raf = new RandomAccessFile(signedFile, "rw"))
                    {
                        raf.seek(offset);
                        raf.write(Hex.getBytes(cmsSignature));
                    }
                }
                else
                {
                    externalSigning.setSignature(cmsSignature);
                }
            }
            else
            {
                doc.saveIncremental(fos);
            }
        }


        IOUtils.closeQuietly(signatureOptions);
    }

    // Find an existing signature (assumed to be empty). You will usually not need this.
    private PDSignature findExistingSignature(PDDocument doc, String sigFieldName)
    {
        PDSignature signature = null;
        PDSignatureField signatureField;
        PDAcroForm acroForm = doc.getDocumentCatalog().getAcroForm();
        if (acroForm != null)
        {
            signatureField = (PDSignatureField) acroForm.getField(sigFieldName);
            if (signatureField != null)
            {
                // retrieve signature dictionary
                signature = signatureField.getSignature();
                if (signature == null)
                {
                    signature = new PDSignature();
                    // after solving PDFBOX-3524
                    // signatureField.setValue(signature)
                    // until then:
                    signatureField.getCOSObject().setItem(COSName.V, signature);
                }
                else
                {
                    throw new IllegalStateException("The signature field " + sigFieldName + " is already signed.");
                }
            }
        }
        return signature;
    }

    public boolean isLateExternalSigning()
    {
        return lateExternalSigning;
    }
}

EPortDigitalSignInfo 签名信息

/**
 *@description:签名信息
 */
public class EPortDigitalSignInfo {
    private String signerName;
    private String reason;
    private String certLocation;

    public EPortDigitalSignInfo(String signerName, String reason, String certLocation) {
        this.signerName = signerName;
        this.reason = reason;
        this.certLocation = certLocation;
    }

    public String getSignerName() {
        return signerName;
    }

    public void setSignerName(String signerName) {
        this.signerName = signerName;
    }

    public String getReason() {
        return reason;
    }

    public void setReason(String reason) {
        this.reason = reason;
    }

    public String getCertLocation() {
        return certLocation;
    }

    public void setCertLocation(String certLocation) {
        this.certLocation = certLocation;
    }
}

EPortDigitalSignPosition 签名位置、页码、缩放比例

/**
 *@description:签名位置、页码、缩放比例
 */
public class EPortDigitalSignPosition {
    private Double x;
    private Double y;
    private int pageNo;
    private int zoomPercent;

    public EPortDigitalSignPosition(Double x, Double y, int pageNo, int zoomPercent) {
        this.x = x;
        this.y = y;
        this.pageNo = pageNo;
        this.zoomPercent = zoomPercent;
    }

    public Double getX() {
        return x;
    }

    public void setX(Double x) {
        this.x = x;
    }

    public Double getY() {
        return y;
    }

    public void setY(Double y) {
        this.y = y;
    }

    public int getPageNo() {
        return pageNo;
    }

    public void setPageNo(int pageNo) {
        this.pageNo = pageNo;
    }

    public int getZoomPercent() {
        return zoomPercent;
    }

    public void setZoomPercent(int zoomPercent) {
        this.zoomPercent = zoomPercent;
    }
}

TSAClient

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.pdfbox.io.IOUtils;
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
import org.bouncycastle.asn1.nist.NISTObjectIdentifiers;
import org.bouncycastle.asn1.oiw.OIWObjectIdentifiers;
import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers;
import org.bouncycastle.tsp.*;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.math.BigInteger;
import java.net.URL;
import java.net.URLConnection;
import java.security.MessageDigest;
import java.security.SecureRandom;

/**
 * Time Stamping Authority (TSA) Client [RFC 3161].
 * @author Vakhtang Koroghlishvili
 * @author John Hewson
 */
public class TSAClient
{
    private static final Log LOG = LogFactory.getLog(TSAClient.class);

    private final URL url;
    private final String username;
    private final String password;
    private final MessageDigest digest;

    /**
     *
     * @param url the URL of the TSA service
     * @param username user name of TSA
     * @param password password of TSA
     * @param digest the message digest to use
     */
    public TSAClient(URL url, String username, String password, MessageDigest digest)
    {
        this.url = url;
        this.username = username;
        this.password = password;
        this.digest = digest;
    }

    /**
     *
     * @param messageImprint imprint of message contents
     * @return the encoded time stamp token
     * @throws IOException if there was an error with the connection or data from the TSA server,
     *                     or if the time stamp response could not be validated
     */
    public byte[] getTimeStampToken(byte[] messageImprint) throws IOException
    {
        digest.reset();
        byte[] hash = digest.digest(messageImprint);

        // 32-bit cryptographic nonce
        SecureRandom random = new SecureRandom();
        int nonce = random.nextInt();

        // generate TSA request
        TimeStampRequestGenerator tsaGenerator = new TimeStampRequestGenerator();
        tsaGenerator.setCertReq(true);
        ASN1ObjectIdentifier oid = getHashObjectIdentifier(digest.getAlgorithm());
        TimeStampRequest request = tsaGenerator.generate(oid, hash, BigInteger.valueOf(nonce));

        // get TSA response
        byte[] tsaResponse = getTSAResponse(request.getEncoded());

        TimeStampResponse response;
        try
        {
            response = new TimeStampResponse(tsaResponse);
            response.validate(request);
        }
        catch (TSPException e)
        {
            throw new IOException(e);
        }

        TimeStampToken token = response.getTimeStampToken();
        if (token == null)
        {
            throw new IOException("Response does not have a time stamp token");
        }

        return token.getEncoded();
    }

    // gets response data for the given encoded TimeStampRequest data
    // throws IOException if a connection to the TSA cannot be established
    private byte[] getTSAResponse(byte[] request) throws IOException
    {
        LOG.debug("Opening connection to TSA server");

        URLConnection connection = url.openConnection();
        connection.setDoOutput(true);
        connection.setDoInput(true);
        connection.setRequestProperty("Content-Type", "application/timestamp-query");

        LOG.debug("Established connection to TSA server");

        if (username != null && password != null && !username.isEmpty() && !password.isEmpty())
        {
            connection.setRequestProperty(username, password);
        }

        // read response
        OutputStream output = null;
        try
        {
            output = connection.getOutputStream();
            output.write(request);
        }
        finally
        {
            IOUtils.closeQuietly(output);
        }

        LOG.debug("Waiting for response from TSA server");

        InputStream input = null;
        byte[] response;
        try
        {
            input = connection.getInputStream();
            response = IOUtils.toByteArray(input);
        }
        finally
        {
            IOUtils.closeQuietly(input);
        }

        LOG.debug("Received response from TSA server");

        return response;
    }

    // returns the ASN.1 OID of the given hash algorithm
    private ASN1ObjectIdentifier getHashObjectIdentifier(String algorithm)
    {
        switch (algorithm)
        {
            case "MD2":
                return new ASN1ObjectIdentifier(PKCSObjectIdentifiers.md2.getId());
            case "MD5":
                return new ASN1ObjectIdentifier(PKCSObjectIdentifiers.md5.getId());
            case "SHA-1":
                return new ASN1ObjectIdentifier(OIWObjectIdentifiers.idSHA1.getId());
            case "SHA-224":
                return new ASN1ObjectIdentifier(NISTObjectIdentifiers.id_sha224.getId());
            case "SHA-256":
                return new ASN1ObjectIdentifier(NISTObjectIdentifiers.id_sha256.getId());
            case "SHA-384":
                return new ASN1ObjectIdentifier(NISTObjectIdentifiers.id_sha384.getId());
            case "SHA-512":
                return new ASN1ObjectIdentifier(NISTObjectIdentifiers.id_sha512.getId());
            default:
                return new ASN1ObjectIdentifier(algorithm);
        }
    }
}

  • 5
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值