python 自动化pdf数字证书签名#

背景#

之前介绍过小微企业电子合同防篡改实现,手动操作有些繁琐,能否做成自动化脚本?

功能#

  1. pdf 导出jpg

  2. jpg 转pdf

  3. 所有者权限控制

  4. 数字签名

实现#

上面4个功能,每个功能的实现可以有多种工具,但是能够全部实现的却没找到。只能结合多个工具来实现。

java 实现#

java 平台只能实现 1 2 3/4 功能,3和4只能选择一个

# 基础路径与密码配置
ROOT_DIR="/Users/Downloads"
PDF_ROOT_DIR="${ROOT_DIR}/workspace"
OUTPUT_DIR="${PDF_ROOT_DIR}/signed_restricted_pdfs"
CERT_PATH="${PDF_ROOT_DIR}/test.p12"    # 替换为你的证书实际路径
CERT_PASS="12345678"                    # 替换为证书私钥密码
PERMISSION_PASS="owner_password"        # 替换为PDF的权限(编辑)密码

# 创建输出目录
mkdir -p "$OUTPUT_DIR"

# 1. 输出图片
java -jar ${ROOT_DIR}/pdfbox-app-3.0.7.jar render -format=jpg -dpi=200  -i=input.pdf
# 2. 生成pdf
java -jar ${ROOT_DIR}/pdfbox-app-3.0.7.jar fromimage  -pageSize=A4 -autoOrientation -resize -o=output1.pdf -i=input-1.jpg

# 3. 加密
java -jar ${ROOT_DIR}/pdfbox-app-3.0.7.jar encrypt \
  -O="$PERMISSION_PASS" \
  -U="" \
  -keyLength=256 \
  -canPrint=true \
  -canModify=false \
  -canExtractContent=false \
  -canAssemble=false \
  -canExtractForAccessibility=false \
  -canFillInForm=false \
  -canModifyAnnotations=false \
  -o=output2.pdf \
  -i=output1.pdf
# 4. 证书签名
java -jar ${ROOT_DIR}/jsignpdf-3.1.0-BETA-3min/lib/jsignpdf-bootstrap-3.1.0-3.jar -kst PKCS12 \
         -ksf "$CERT_PATH" \
         -ksp "$CERT_PASS" \
         -opwd "$PERMISSION_PASS" \
         output2.pdf

# 5. 处理完后,删除掉临时文件
rm  -rf input*.jpg
rm  -rf output*.pdf

python 实现#

python 平台全部实现 1 2 3 4 功能


import glob
import pymupdf as fitz
import sys
import os
from pyhanko.sign import signers,PdfSignatureMetadata
from pyhanko.pdf_utils.incremental_writer import IncrementalPdfFileWriter
from pyhanko.pdf_utils.reader import PdfFileReader
from pyhanko.sign.fields import MDPPerm,SigSeedSubFilter

import argparse
parser = argparse.ArgumentParser(description='location the pdf file')
parser.add_argument("-f","--pdf", type=str, required=True, help="the path of pdf file")
args = parser.parse_args()

pdf_file_path=args.pdf

imgs_dir=""
file_name=""
# 基础路径与密码配置
ROOT_DIR="/Users/Downloads"
PDF_ROOT_DIR=ROOT_DIR+"/workspace"
CERT_PATH=PDF_ROOT_DIR+"/test.pfx"   # 替换为你的证书实际路径
CERT_PASS=b'12345678'               # 替换为证书私钥密码
PERMISSION_PASS="OWNERPWD"          # 替换为PDF的权限(编辑)密码

def pdf_to_jpg(pdf_path):
    if not os.path.exists(pdf_path):
        print(f"找不到文件: {pdf_path}")
        return

    # 在同级目录下创建一个以 PDF 名字命名的文件夹存放图片
    base_name = os.path.splitext(os.path.basename(pdf_path))
    output_dir = os.path.join(os.path.dirname(pdf_path), f"{base_name[0]}_images")
    print(f"已创建目录: {output_dir}")
    os.makedirs(output_dir, exist_ok=True)
    global imgs_dir
    imgs_dir=output_dir
    global file_name
    file_name=base_name[0]

    doc = fitz.open(pdf_path)
    zoom = 2.0  # 2倍缩放,保证清晰度
    mat = fitz.Matrix(zoom, zoom)

    for i, page in enumerate(doc):
        pix = page.get_pixmap(matrix=mat)
        img_path = os.path.join(output_dir, f"page_{i+1}.jpg")
        pix.save(img_path, jpg_quality=90)
        print(f"已导出: {img_path}")
    
    doc.close()
    print("✅ PDF导出图片全部导出完成!")


def images_to_pdf(image_path, output_pdf_path="img2pdfout.pdf"):
    doc = fitz.open()
    image_paths=os.listdir(image_path)
    # 按照文件名排序,保证 PDF 中图片的顺序
    sorted_images = sorted(image_paths)
    print(sorted_images)
    for img_path in sorted_images:
        if not img_path.lower().endswith(('.jpg', '.jpeg', '.png')):
            print(f"跳过非图片文件: {img_path}")
            continue
        try:
            img_doc = fitz.open(os.path.join(image_path, img_path))
            rect = img_doc[0].rect
            page = doc.new_page(width=rect.width, height=rect.height)
            # 将图片铺满整个页面
            # page.show_pdf_page(rect, img_doc, 0)
            page.insert_image(rect, filename=os.path.join(image_path, img_path))
            img_doc.close()
            print(f"已添加图片: {os.path.basename(img_path)}")
        except Exception as e:
            print(f"处理图片 {img_path} 时出错: {e}")
            continue
            
    if len(doc) == 0:
        print("没有有效的图片可以转换为 PDF")
        return
        
    doc.save(imgs_dir+"/"+output_pdf_path)
    doc.close()
    print(f"✅ 成功生成 PDF 文件: {imgs_dir}/{output_pdf_path}")

def protect_pdf(input_path, output_path, owner_pw, user_pw="", can_print=True, can_copy=False):
    try:
        doc = fitz.open(input_path)
        
        # 基础权限:允许辅助功能读取
        permissions = 0
        if can_print:
            permissions |= fitz.PDF_PERM_PRINT
        if can_copy:
            permissions |= fitz.PDF_PERM_COPY
        # 如果需要允许注释/填写表单,可以加上 fitz.PDF_PERM_ANNOTATE
        
        doc.save(
            output_path,
            encryption=fitz.PDF_ENCRYPT_AES_256,
            owner_pw=owner_pw,
            user_pw=user_pw,
            permissions=permissions,
        )
        doc.close()
        print(f"✅ PDF 已成功加密并保存至: {output_path}")
    except Exception as e:
        print(f"处理文件 {input_path} 时出错: {e}")


def changepdf(input_path, output_path):
    doc = fitz.open(input_path)

    # 重新打开,修复直接对象问题
    doc = fitz.open(output_path)
    # 获取 Trailer 中的 /Encrypt 键的类型和值
    what, xref_or_str = doc.xref_get_key(-1, "Encrypt")

    # 如果返回的是 "null" 或其他,说明没加密;如果是 "xref",说明已经是间接对象
    # 如果返回的是 "dict",说明它是直接对象,需要修复
    if what == "dict":
        # 将直接对象转换为间接对象
        # 1. 创建一个新的间接对象
        new_xref = doc.get_new_xref()
        # 2. 将原来的直接对象内容写入新的间接对象
        doc.update_object(new_xref, xref_or_str)
        # 3. 将 Trailer 中的 /Encrypt 指向这个新的间接对象
        doc.xref_set_key(-1, "Encrypt", f"{new_xref} 0 R")
        
        # 增量保存修复结果
        doc.saveIncr()

    doc.close()
    # 此时生成的 encrypted.pdf 就可以被 PyHanko 正常读取了
    print(f"✅ PDF 加密格式已经转换: {output_path}")


def sign_pdf(input_path, output_path, cert_path, cert_pass, permission_pass, sig_meta):
    # ====================== 配置区 ======================
    # input_path            # 待签名PDF
    # output_path           # 签名后PDF
    # cert_path             # 你的PFX证书
    # cert_pass             # 证书密码
    # ====================================================

    # 1. 加载PFX证书
    signer = signers.SimpleSigner.load_pkcs12(
        pfx_file=cert_path,
        passphrase=cert_pass  # 密码必须转bytes
    )

    # 2. 【不可见数字签名】核心代码
    with open(input_path, "rb") as input_file,open(output_path, "wb") as output_file:
        reader = PdfFileReader(input_file)
        reader.decrypt(permission_pass)
        input_file.seek(0)  # 解密后重置文件指针到开头
        # 增量写入(不破坏原PDF结构)
        writer = IncrementalPdfFileWriter(input_file)
        writer.encrypt(permission_pass)
        
        signers.sign_pdf(
            writer,
            signer=signer,
            output=output_file,
            signature_meta=sig_meta
            
        )

    print(f"✅ 签名完成!输出文件:{output_path}")
    print("ℹ️  原PDF的所有者密码/权限已保留")



if __name__ == "__main__":
    
    pdf_to_jpg(pdf_file_path)
    images_to_pdf(imgs_dir,file_name+"_imgpdf.pdf")
    protect_pdf(imgs_dir+"/"+file_name+"_imgpdf.pdf", imgs_dir+"/"+file_name+"_protected.pdf", PERMISSION_PASS, "", can_print=True, can_copy=False)

    sig_meta=PdfSignatureMetadata(
        # 签名基本信息
        reason="文档正式签署",
        location="北京",
        name="签名人姓名",   # ✅ 用 name 代替 contact
        # PAdES 等级(常用)
        subfilter=SigSeedSubFilter.PADES,                # 启用 PAdES

        field_name="Signature1",#  ✅ 必须指定一个唯一的字段名
        md_algorithm="sha256",
        certify=False,
        docmdp_permissions=MDPPerm.FILL_FORMS,  # ✅ 正确名字是 docmdp_permissions
    )
    changepdf(imgs_dir+"/"+file_name+"_protected.pdf", imgs_dir+"/"+file_name+"_protected.pdf")
    sign_pdf(imgs_dir+"/"+file_name+"_protected.pdf", imgs_dir+"/"+file_name+"_signed.pdf", CERT_PATH, CERT_PASS, PERMISSION_PASS, sig_meta)

    # 清理临时文件
    os.remove(imgs_dir+"/"+file_name+"_imgpdf.pdf")
    os.remove(imgs_dir+"/"+file_name+"_protected.pdf")
    for f in glob.glob(imgs_dir+"/page_*.jpg"):
        os.remove(f)