python 自动化pdf数字证书签名#
背景#
之前介绍过小微企业电子合同防篡改实现,手动操作有些繁琐,能否做成自动化脚本?
功能#
pdf 导出jpg
jpg 转pdf
所有者权限控制
数字签名
实现#
上面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)