1. 程式人生 > >itext實現pdf自動定位合同簽訂

itext實現pdf自動定位合同簽訂

需求

  1. 需要實現如下效果(最終效果)

思考

  1. 需求方的要求就是實現簽訂合同,實現方法不限,但過程中又提出需要在簽章的過程中把簽訂日期的文字也打上去,這就有點坑了~
  2. 一開始的想法是想辦法定位需要簽名的位置,事實上同類app實現方式就是這樣,在前端實現簽名位置定位,把位置資訊發給後端,後端就可以很方便把印章放上去。
  3. 但現實是現在前端不靠譜,暫時不能提供這樣的功能;而且日期資訊的填寫也需要定位,這怎麼辦?使用者不會手動去定位日期的位置,最多會調整下簽名的位置才合理
  4. 然後我研究了下itext的api,並討論決定尾部簽名部分我們自己做。也就是上圖中的下半部分的所有內容,包括甲方乙方,日期,簽章等都通過程式自動定位上去
  5. 這樣的想法遇到的難點,首先是y軸的定位問題。首先要找到文件的尾行在哪,在適當的距離進行文字的填寫。我沒有找到可以直接在文件末尾新增文字的api,如果各位知道麻煩指教一下

步驟

  1. 因為有上述的問題,我首先考慮要找到尾行的文字才會考慮寫程式碼。通過api研究,可以通過itext的監聽器遍歷文字拿到尾行文字等資訊
  2. x周位置根據頁面寬度調整
  3. 文字大小和字型型別問題。字型型別是我現在也沒解決的,我沒找到獲取pdf文件字型型別和大小的api,請指教
  4. 因為沒找到api所以我用的最笨的方法,通過獲取字型的高度來確定字型大小,這樣的文字寫出來差別不會太大。至於字型,只能認為規定,合同字型統一宋體。
  5. 過程中還遇到的問題就是字型左邊距對齊問題,很明顯甲乙方在一行上,中間用空格來分割的話會很不標準。所以我最終決定用table,且左右邊簽名和文字分開進行寫入。也就是甲籤的時候寫左半部分,乙籤的時候寫右半部分。當簽完後就是上圖的效果
  6. 說了這麼多接下來直接上工具程式碼吧,如果要使用,直接把幾個類程式碼複製過去,把字型路徑換成自己的,檔案路徑改下就可以在main方法執行測試了

上程式碼

  1. PdfParser類,主要實現類,包含了main方法
package com.zhiyis.framework.util.itext;

import com.itextpdf.io.font.PdfEncodings;
import com.itextpdf.kernel.font.PdfFont;
import com.itextpdf.kernel.font.PdfFontFactory;
import com.itextpdf.kernel.geom.Rectangle;
import com.itextpdf.kernel.geom.Vector;
import com.itextpdf.kernel.pdf.PdfDocument;
import com.itextpdf.kernel.pdf.PdfReader;
import com.itextpdf.kernel.pdf.PdfWriter;
import com.itextpdf.kernel.pdf.canvas.parser.EventType;
import com.itextpdf.kernel.pdf.canvas.parser.PdfDocumentContentParser;
import com.itextpdf.kernel.pdf.canvas.parser.data.IEventData;
import com.itextpdf.kernel.pdf.canvas.parser.data.TextRenderInfo;
import com.itextpdf.kernel.pdf.canvas.parser.listener.IEventListener;
import com.itextpdf.layout.Document;
import com.itextpdf.layout.borders.Border;
import com.itextpdf.layout.element.Cell;
import com.itextpdf.layout.element.Paragraph;
import com.itextpdf.layout.element.Table;
import com.zhiyis.common.utils.DateUtil;
import com.zhiyis.common.utils.Sysconfig;
import com.zhiyis.framework.util.FileUtil;
import com.zhiyis.framework.util.SignPdf;
import lombok.extern.slf4j.Slf4j;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.util.*;


/**
 * @author laoliangliang
 * @date 2018/11/23 15:03
 */
@Slf4j
public class PdfParser {

    private Sysconfig sysconfig;

    public PdfParser() {
    }

    public PdfParser(Sysconfig sysconfig) {
        this.sysconfig = sysconfig;
    }

    public enum SignType {
        //甲籤
        SIGN_A(1),
        //乙籤
        SIGN_B(2);
        private Integer type;

        SignType(Integer type) {
            this.type = type;
        }

        public Integer getType() {
            return type;
        }
    }

    public static void main(String[] args) {
        List<String> contents = new ArrayList<>();
        contents.add("甲方法定代表人:");
        contents.add("聯絡電話:");
        contents.add("身份證號碼:");
        contents.add(DateUtil.format2str("yyyy 年  MM 月  dd 日"));
        String input = "/Users/laoliangliang/Downloads/合同模板 (1).pdf";
        String tempPath = "/Users/laoliangliang/Downloads/合同模板_signed.pdf";

        String filePath = "/Users/laoliangliang/Downloads/31.png";
        String fileOut = "/Users/laoliangliang/Downloads/合同模板_signed_signed_signed.pdf";
        PdfParser pdfParser = new PdfParser();
//        pdfParser.startSign(input, input, fileOut, filePath, SignType.SIGN_A, contents, false);
        pdfParser.startSign(input, fileOut, tempPath, filePath, SignType.SIGN_B, contents, true);
    }

    /**
     * 甲乙方簽名方法
     *
     * @param rootPath 初始合同pdf路徑
     * @param tempPath 基於哪份合同簽章,比如甲方先簽,這裡填的就是初始合同地址;若是乙方籤,這裡填的就是甲方簽過生成的合同地址
     * @param outPath  輸出的合同地址,包含檔名
     * @param imgPath  簽章圖片地址
     * @param signType 甲方簽章還是乙方簽章,輸入列舉型別
     * @param contents 簽章處文字內容
     * @param already  理論上甲籤的時候是false,表示沒有簽過,乙籤的時候是true,表示甲已經簽過,就算下面高度不夠也不會新增頁面
     *                 若需求改動,可以乙先簽,那邏輯控制,先簽的false,後籤的true;
     *                 該項錯誤可能導致第二方簽章時新啟一頁簽章
     */
    public void startSign(String rootPath, String tempPath, String outPath, String imgPath, SignType signType, List<String> contents, boolean already) {
        String tempRootPath = "";
        try {
            //讀取文章尾部位置
            MyRectangle myRectangle = getLastWordRectangle(rootPath);
            //還沒簽印的,臨時檔案路徑
            tempRootPath = rootPath.substring(0, rootPath.length() - 4) + "_temp.pdf";
            //新增尾部內容
            SignPosition signPosition = addTailSign(myRectangle, tempPath, tempRootPath, signType.getType(), contents, already);
            InputStream in = PdfParser.class.getClassLoader().getResourceAsStream("keystore.p12");
            byte[] fileData = SignPdf.sign("123456", in, tempRootPath, imgPath, signPosition.getX(), signPosition.getY(), signPosition.getPageNum());
            FileUtil.uploadFile(fileData, outPath);
        } catch (Exception e) {
            log.error("簽名出錯", e);
        } finally {
            File file = new File(tempRootPath);
            if (file.exists()) {
                boolean flag = file.delete();
                if (flag) {
                    log.debug("臨時檔案刪除成功");
                }
            }
        }
    }

    /**
     * 新增尾部簽名部分(不含簽名或印章)
     *
     * @param myRectangle 文件末尾位置和大致資訊
     * @param input       輸入文件路徑
     * @param output      輸出文件路徑
     * @param type        1-甲籤 2-乙籤
     * @param content     填寫內容
     * @param already     理論上甲籤的時候是false,表示沒有簽過,乙籤的時候是true,表示甲已經簽過,就算下面高度不夠也不會新增頁面
     *                    若需求改動,可以乙先簽,那邏輯控制,先簽的false,後籤的true
     * @throws Exception
     */
    private SignPosition addTailSign(MyRectangle myRectangle, String input, String output, Integer type, List<String> content, boolean already) throws Exception {

        PdfReader reader = new PdfReader(input);
        PdfWriter writer = new PdfWriter(output);
        PdfDocument pdf = new PdfDocument(reader, writer);
        int numberOfPages = pdf.getNumberOfPages();

        Document doc = new Document(pdf);
        String dateFontPath;
        if (sysconfig == null) {
            dateFontPath = "/Library/Fonts/simsun.ttc";
        }else{
            dateFontPath = sysconfig.getProperties().getProperty("date_font_path");
        }
        PdfFont font = PdfFontFactory.createFont(dateFontPath + ",1", PdfEncodings.IDENTITY_H, true);
        //判斷簽名高度是否夠
        int size = content.size();
        float maxRecHeight = myRectangle.getMinlineHeight() * size;
        float v = myRectangle.getBottom() - maxRecHeight;
        boolean isNewPage = false;
        if (v <= myRectangle.getMinlineHeight() * 3) {
            isNewPage = true;
            if (!already) {
                pdf.addNewPage();
                numberOfPages++;
            }
            myRectangle.setBottom(myRectangle.getTop() * 2 - maxRecHeight * 2);
        }
        Table table = new Table(1);
        table.setPageNumber(numberOfPages);
        float bottom = (myRectangle.getBottom() - maxRecHeight) / 2;
        float left1;
        left1 = myRectangle.getLeft() + 30f;
        if (type == 2) {
            left1 = left1 + myRectangle.getWidth() / 2 - 15;
        }
        myRectangle.setLeft(left1);
        table.setFixedPosition(left1, bottom, 200);
        table.setBorder(Border.NO_BORDER);


        for (String text : content) {
            Paragraph paragraph = new Paragraph();
            paragraph.add(text).setFont(font).setFontSize(myRectangle.getHeight());
            Cell cell = new Cell();
            cell.add(paragraph);
            cell.setBorder(Border.NO_BORDER);
            table.addCell(cell);
        }

        doc.add(table);
        doc.flush();
        pdf.close();
        return getSignPosition(myRectangle, content, bottom, numberOfPages, isNewPage);
    }

    private SignPosition getSignPosition(MyRectangle myRectangle, List<String> content, float bottom, int numberOfPages, boolean isNewPage) {
        SignPosition signPosition = new SignPosition();
        //y軸位置,底部
        if (isNewPage) {
            signPosition.setY(bottom + (content.size() - 2) * myRectangle.getMinlineHeight());
        } else {
            signPosition.setY(bottom + (content.size() - 3) * myRectangle.getMinlineHeight());
        }
        //x軸位置,文字寬度+偏移量
        signPosition.setX(myRectangle.getLeft() + content.get(0).length() * myRectangle.getHeight() - 15f);
        signPosition.setPageNum(numberOfPages);
        return signPosition;
    }

    /**
     * 拿到文章末尾引數
     */
    private MyRectangle getLastWordRectangle(String input) throws IOException {
        PdfDocument pdfDocument = new PdfDocument(new PdfReader(input));
        MyEventListener myEventListener = new MyEventListener();
        PdfDocumentContentParser parser = new PdfDocumentContentParser(pdfDocument);
        parser.processContent(pdfDocument.getNumberOfPages(), myEventListener);
        List<Rectangle> rectangles = myEventListener.getRectangles();
        float left = 100000;
        float right = 0;
        float bottom = 100000;
        boolean isTop = true;
        Rectangle tempRec = null;
        float minV = 1000;
        MyRectangle myRectangle = new MyRectangle();
        //拿到文字最左最下和最右位置
        for (Rectangle rectangle : rectangles) {
            if (isTop) {
                myRectangle.setTop(rectangle.getY());
                isTop = false;
            }
            if (tempRec != null) {
                float v = tempRec.getY() - rectangle.getY();
                if (v < minV && v > 5f) {
                    minV = v;
                }
            }
            tempRec = rectangle;
            float lt = rectangle.getLeft();
            float rt = rectangle.getRight();
            float y = rectangle.getBottom();
            if (lt < left) {
                left = lt;
            }
            if (rt > right) {
                right = rt;
            }
            if (y < bottom) {
                bottom = y;
            }

        }
        Rectangle rectangle = rectangles.get(rectangles.size() - 1);
        float height = rectangle.getHeight();
        myRectangle.setHeight(height);
        myRectangle.setLeft(left);
        myRectangle.setRight(right);
        myRectangle.setBottom(bottom);
        myRectangle.setMinlineHeight(minV);
        myRectangle.setLineSpace(minV - height);
        myRectangle.setWidth(right - left);
        pdfDocument.close();
        return myRectangle;
    }


    static class MyEventListener implements IEventListener {
        private List<Rectangle> rectangles = new ArrayList<>();

        @Override
        public void eventOccurred(IEventData data, EventType type) {
            if (type == EventType.RENDER_TEXT) {
                TextRenderInfo renderInfo = (TextRenderInfo) data;
                if ("".equals(renderInfo.getText().trim())) {
                    return;
                }
                Vector startPoint = renderInfo.getDescentLine().getStartPoint();
                Vector endPoint = renderInfo.getAscentLine().getEndPoint();
                float x1 = Math.min(startPoint.get(0), endPoint.get(0));
                float x2 = Math.max(startPoint.get(0), endPoint.get(0));
                float y1 = Math.min(startPoint.get(1), endPoint.get(1));
                float y2 = Math.max(startPoint.get(1), endPoint.get(1));
                rectangles.add(new Rectangle(x1, y1, x2 - x1, y2 - y1));
            }
        }

        @Override
        public Set<EventType> getSupportedEvents() {
            return new LinkedHashSet<>(Collections.singletonList(EventType.RENDER_TEXT));
        }

        public List<Rectangle> getRectangles() {
            return rectangles;
        }

        public void clear() {
            rectangles.clear();
        }
    }

}
  1. MyRectangle 用來存文件尾部資料的實體類
package com.zhiyis.framework.util.itext;

/**
 * @author laoliangliang
 * @date 2018/11/23 16:11
 */
public class MyRectangle {

    private float width;
    private float left;
    private float right;
    private float bottom;
    private float top;
    private float height;
    /**
     * 行間間隔
     */
    private float lineSpace;
    /**
     * 最小行間距,從上一行底部到下一行底部的距離
     */
    private float minlineHeight;
    public float getWidth() {
        return width;
    }

    public void setWidth(float width) {
        this.width = width;
    }
    public float getLeft() {
        return left;
    }

    public void setLeft(float left) {
        this.left = left;
    }

    public float getRight() {
        return right;
    }

    public void setRight(float right) {
        this.right = right;
    }

    public float getBottom() {
        return bottom;
    }

    public void setBottom(float bottom) {
        this.bottom = bottom;
    }

    public float getHeight() {
        return height;
    }

    public void setHeight(float height) {
        this.height = height;
    }

    public float getLineSpace() {
        return lineSpace;
    }

    public void setLineSpace(float lineSpace) {
        this.lineSpace = lineSpace;
    }

    public float getMinlineHeight() {
        return minlineHeight;
    }

    public void setMinlineHeight(float minlineHeight) {
        this.minlineHeight = minlineHeight;
    }

    public float getTop() {
        return top;
    }

    public void setTop(float top) {
        this.top = top;
    }
}
  1. SignPosition 簽章位置類
package com.zhiyis.framework.util.itext;

/**
 * 簽章位置類
 * @author laoliangliang
 * @date 18/11/24 下午1:43
 */
public class SignPosition {

    private float x;

    private float y;

    private float width;

    private float height;

    private Integer pageNum;

    public Integer getPageNum() {
        return pageNum;
    }

    public void setPageNum(Integer pageNum) {
        this.pageNum = pageNum;
    }

    public float getX() {
        return x;
    }

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

    public float getY() {
        return y;
    }

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

    public float getWidth() {
        return width;
    }

    public void setWidth(float width) {
        this.width = width;
    }

    public float getHeight() {
        return height;
    }

    public void setHeight(float height) {
        this.height = height;
    }
}
  1. SignPdf 簽章類
package com.zhiyis.framework.util;

import com.itextpdf.text.Image;
import com.itextpdf.text.Rectangle;
import com.itextpdf.text.pdf.PdfReader;
import com.itextpdf.text.pdf.PdfSignatureAppearance;
import com.itextpdf.text.pdf.PdfSignatureAppearance.RenderingMode;
import com.itextpdf.text.pdf.PdfStamper;
import com.itextpdf.text.pdf.security.*;
import com.itextpdf.text.pdf.security.MakeSignature.CryptoStandard;
import org.bouncycastle.jce.provider.BouncyCastleProvider;

import java.io.*;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.Security;
import java.security.cert.Certificate;
import java.util.UUID;

/**
 * 簽印
 */
public class SignPdf {
    /**
     * @param password     祕鑰密碼
     * @param inputStream  祕鑰檔案
     * @param signPdfSrc   簽名的PDF檔案
     * @param signImage    簽名圖片檔案
     * @param x            x座標
     * @param y            y座標
     * @return
     */
    public static byte[] sign(String password, InputStream inputStream, String signPdfSrc, String signImage,
                              float x, float y,int page) {
        File signPdfSrcFile = new File(signPdfSrc);
        PdfReader reader = null;
        ByteArrayOutputStream signPDFData = null;
        PdfStamper stp = null;
        try {
            BouncyCastleProvider provider = new BouncyCastleProvider();
            Security.addProvider(provider);
            KeyStore ks = KeyStore.getInstance("PKCS12", new BouncyCastleProvider());
            // 私鑰密碼 為Pkcs生成證書是的私鑰密碼 123456
            ks.load(inputStream, password.toCharArray());
            String alias = (String) ks.aliases().nextElement();
            PrivateKey key = (PrivateKey) ks.getKey(alias, password.toCharArray());
            Certificate[] chain = ks.getCertificateChain(alias);
            reader = new PdfReader(signPdfSrc);
            signPDFData = new ByteArrayOutputStream();
            // 臨時pdf檔案
            File temp = new File(signPdfSrcFile.getParent(), System.currentTimeMillis() + ".pdf");
            stp = PdfStamper.createSignature(reader, signPDFData, '\0', temp, true);
            stp.setFullCompression();
            PdfSignatureAppearance sap = stp.getSignatureAppearance();
            sap.setReason("數字簽名,不可改變");
            // 使用png格式透明圖片
            Image image = Image.getInstance(signImage);
            sap.setImageScale(0);
            sap.setSignatureGraphic(image);
            sap.setRenderingMode(RenderingMode.GRAPHIC);
            int size = 120;
            // 是對應x軸和y軸座標
            float lly = y;
            sap.setVisibleSignature(new Rectangle(x, lly, x + size, lly+size), page,
                    UUID.randomUUID().toString().replaceAll("-", ""));
            stp.getWriter().setCompressionLevel(5);
            ExternalDigest digest = new BouncyCastleDigest();
            ExternalSignature signature = new PrivateKeySignature(key, DigestAlgorithms.SHA512, provider.getName());
            MakeSignature.signDetached(sap, digest, signature, chain, null, null, null, 0, CryptoStandard.CADES);
            stp.close();
            reader.close();
            return signPDFData.toByteArray();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {

            if (signPDFData != null) {
                try {
                    signPDFData.close();
                } catch (IOException e) {
                }
            }

            if (inputStream != null) {
                try {
                    inputStream.close();
                } catch (IOException e) {
                }
            }
        }
        return null;
    }

}
  1. 工具方法
public static boolean uploadFile(byte[] file, String filePath) throws Exception {
    String tempPath = filePath.substring(0,filePath.lastIndexOf("/"));
    File targetFile = new File(tempPath);
    if(!targetFile.exists()) {
        boolean out = targetFile.mkdirs();
        if(out) {
            log.info(filePath + " create success");
        } else {
            log.info(filePath + " create fail");
        }
    }

    FileOutputStream out1 = new FileOutputStream(filePath);
    out1.write(file);
    out1.flush();
    out1.close();
    File f = new File(filePath);
    return f.exists();
}

總結

  1. 公私鑰的生成網上很多就自己去生成吧
  2. 如果想要測試效果的可以把簽章部分先去掉也可以執行
  3. 我覺得這篇部落格是我最有含金量的一篇了~我找了很多部落格定位pdf簽章的沒有靠譜的,很多技術實現都很複雜,我最初版本,也就是前面有一篇部落格實現就是改編自網上一篇部落格的,但是有很多問題,程式碼也過於複雜難懂,彎彎繞繞且難以修改增強。
  4. 我研究了官方最新程式碼結合自己腦洞大開的思路,精簡出了很簡單的三個類,其實排除實體類,真正實現功能就一個PdfParser
  5. **如果覺得有用給我點個贊哦^_^**