前言
在批量 PDF 翻译的流水线里,有一类文件特别麻烦:带数字签名的 PDF。典型场景是电子合同、电子发票、加盖签章的报价单。这类文件如果被程序"打开—改内容—另存",签名往往会失效,而失效的合同在业务上是废纸。
问题的关键不在于"能不能改",而在于用什么方式保存。本文用一组可复现的实测,把 PDF 签名的检测方法、不同保存方式对签名的影响、以及实际开发中踩到的两个坑讲清楚。
顺便说清一个前提:翻译回填本身是"必须改文件"的操作——要么替换原文字,要么在旁侧插入译文图层。也就是说,只要你的流水线要处理带签名的 PDF,就一定会碰到"改了之后签名怎么办"这个问题。所以正确的思路不是"怎么改才能让签名看起来还有效",而是先判断这份文件能不能改,能改的用哪种保存方式才不破坏已签名的字节。这也是本文实验的设计出发点。
环境准备
- Python 3.13
- 依赖:
pip install pymupdf pypdf reportlab numpy - 实测版本:PyMuPDF 1.28.2 / pypdf 6.18.0 / reportlab 5.0.1
一、先理解签名为什么会被破坏
PDF 签名的原理是:签名时把文件的一段字节区间算出一个摘要(digest),并把摘要和证书信息写在签名对象里,然后用 /ByteRange 记录"我覆盖了哪些字节范围"。验签时重新算这段范围的摘要,与签名里存的比对,一致就说明签完之后这些字节没被动过。
签名对象长这样:
<< /Type /Sig
/Filter /Adobe.PPKLite
/SubFilter /adbe.pkcs7.detached
/Contents <....数字签名值....>
/ByteRange [0 45391 45647 46234] >>
/ByteRange [0 a b c] 的含义是:覆盖 [0, a) 和 [b, c) 两段,中间 [a, b) 正是 /Contents 那个签名值本身(它当然不能覆盖自己)。
由此可以直接推出本文的核心结论:任何会导致"已签名区间字节发生变化"的保存方式,都会让签名失效。 完整重写就是典型——它会重新排版整个文件,字节区间全变了。
二、怎么检测 PDF 里有没有签名
先解决"有没有"的问题,这部分不依赖任何签名验证库:
import re
import pymupdf
def scan_signature(path: str) -> dict:
"""扫描 PDF 里的签名信息
返回:SigFlags、签名域数量、/ByteRange 出现次数
"""
doc = pymupdf.open(path)
# 1) 签名域:AcroForm 里 FT=/Sig 的表单域
fields = [w for w in doc[0].widgets()
if w.field_type == pymupdf.PDF_WIDGET_TYPE_SIGNATURE]
# 2) 文档级标志位,bit0=有签名,bit1=仅允许追加
sigflags = doc.get_sigflags()
doc.close()
# 3) 直接数原始字节里的 /ByteRange(最直接的存在性证据)
raw = open(path, "rb").read()
return {
"SigFlags": sigflags,
"签名域数量": len(fields),
"签名域名": [f.field_name for f in fields],
"ByteRange出现次数": len(re.findall(rb"/ByteRange", raw)),
}
实测输出(样本是我们后面手工构造的带签名文件):
{'file': '无签名基础件', 'get_sigflags': -1, 'AcroForm_SigFlags': None,
'签名域数量': 0, '签名域名': [], '/ByteRange出现次数': 0, '文件大小': 1981}
{'file': '自签样本', 'get_sigflags': 3, 'AcroForm_SigFlags': ('int', '3'),
'签名域数量': 1, '签名域名': ['Signature1'], '/ByteRange出现次数': 1, '文件大小': 46234}
get_sigflags() 返回 3 的含义是 1 | 2:bit0 表示文档里存在签名,bit1(AppendOnly)表示该签名要求后续只能做增量追加。这个值非常有用——看到 AppendOnly 就应该强制走增量保存,别再用默认的 save()。
三、构造一个可校验的签名样本
真实 PKI 签名需要证书,为了把实验做得可复现,我构造了一个只保留签名失效判据的最小样本:用一个定宽的 /ByteRange 占位,落盘后按真实字节偏移回填,再对覆盖范围算 SHA-256 写进 /Contents。这样"摘要一致 = 签名有效"的判据与真实签名完全等价。
import hashlib, re
HEXLEN = 256 # /Contents 占位长度(hex 字符数)
SEED_BR = "[0 1000000000 2000000000 3000000000]" # 定宽种子:10 位、无前导零
def patch_and_sign(path: str) -> dict:
"""长度零变化地回填真实 /ByteRange,并把摘要写进 /Contents
关键:占位与回填的长度必须完全一致。
否则 xref 表里的字节偏移会失效,文件会被 MuPDF 判定为 repaired,
后续连增量保存都会被拒绝。
"""
data = open(path, "rb").read()
m = re.search(rb"/ByteRange\s*\[\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s*\]", data)
idx = data.find(b"0" * HEXLEN) # /Contents 占位起点
c0, c1, size = idx, idx + HEXLEN, len(data)
vals = [f"{c0:010d}", f"{c1:010d}", f"{size:010d}"]
spans = [m.span(2), m.span(3), m.span(4)]
# 从后往前替换,避免前面的替换把后面的偏移带偏
for (s, e), v in reversed(list(zip(spans, vals))):
assert e - s == 10
data = data[:s] + v.encode() + data[e:]
assert len(data) == size, "长度变化会导致 xref 失效"
digest = hashlib.sha256(data[:c0] + data[c1:]).hexdigest()
data = data.replace(b"0" * HEXLEN, digest.encode() + b"0" * (HEXLEN - 64), 1)
open(path, "wb").write(data)
return {"c0": c0, "c1": c1, "filesize": size, "digest": digest}
样本构造出来是 /ByteRange [0 45391 45647 46234],即覆盖前 45391 字节 + 后 587 字节。
验签函数同理,按 /ByteRange 重算摘要即可:
def verify(path: str, label: str = "") -> dict:
"""按 /ByteRange 重算摘要,与 /Contents 比对"""
data = open(path, "rb").read()
m = re.search(rb"/ByteRange\s*\[\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s*\]", data)
a, b, c, d = (int(x) for x in m.groups())
stored = re.search(rb"/Contents\s*<([0-9A-Fa-f]+)>", data).group(1)[:64].decode().lower()
if b > len(data) or d > len(data):
return {"file": label, "status": "INVALID(偏移越界:文件已被重写/缩短)"}
computed = hashlib.sha256(data[a:b] + data[c:d]).hexdigest()
return {"file": label, "status": "VALID(摘要一致)" if computed == stored
else "INVALID(摘要不一致)"}
四、五组对照实验
准备五种最常见的保存方式,全部作用在同一个已签名样本上:
import shutil, pymupdf, pypdf
# A. 完整重写(最常见的默认写法,且没改任何内容)
doc = pymupdf.open("signed.pdf"); doc.save("full_rewrite.pdf"); doc.close()
# B. 增量保存,不改内容
shutil.copy("signed.pdf", "incr_only.pdf")
doc = pymupdf.open("incr_only.pdf")
doc.save("incr_only.pdf", incremental=True, encryption=pymupdf.PDF_ENCRYPT_KEEP)
doc.close()
# C. 增量保存 + 在第 2 页插入译文
shutil.copy("signed.pdf", "incr_translated.pdf")
doc = pymupdf.open("incr_translated.pdf")
doc[1].insert_text((72, 700), "Appendix A (translated)", fontsize=11)
doc.save("incr_translated.pdf", incremental=True, encryption=pymupdf.PDF_ENCRYPT_KEEP)
doc.close()
# D. pypdf 的 append:框架不同,但本质也是完整重写
w = pypdf.PdfWriter(); w.append(pypdf.PdfReader("signed.pdf"))
with open("pypdf_append.pdf", "wb") as f:
w.write(f)
# E. 增量保存 + 修改第 1 页旋转标记(正文完全没动)
shutil.copy("signed.pdf", "incr_rot.pdf")
doc = pymupdf.open("incr_rot.pdf"); doc[0].set_rotation(90)
doc.save("incr_rot.pdf", incremental=True, encryption=pymupdf.PDF_ENCRYPT_KEEP)
doc.close()
# F. 完整重写 + 插入译文
doc = pymupdf.open("signed.pdf")
doc[1].insert_text((72, 700), "Appendix A (translated)", fontsize=11)
doc.save("full_translated.pdf"); doc.close()
实测结果:
| 方案 | 保存方式 | 文件体积 | 原文件前缀是否逐字节保持 | 签名校验 |
|---|---|---|---|---|
| 基线 | — | 46234 B | — | VALID |
| A | 完整重写(未改内容) | 46219 B | 否 | INVALID(偏移越界) |
| B | 增量保存(未改内容) | 46234 B | 是 | VALID |
| C | 增量保存 + 插译文 | 47149 B(+915) | 是 | VALID |
| D | pypdf append 重写 | 46496 B | 否 | INVALID(摘要不一致) |
| E | 增量保存 + 改 /Rotate | 46632 B(+398) | 是 | VALID |
| F | 完整重写 + 插译文 | 46693 B | 否 | INVALID |
几个值得注意的细节:
第一,A 组最反直觉。 它没有修改任何内容,只是"打开再另存",签名照样失效了。原因是完整重写后文件从 46234 字节变成 46219 字节,缩短了 15 字节,/ByteRange 里的末端偏移 46234 已经超出文件末尾,签名在结构上就直接失效了。所以只要走默认 save(),"没改内容"也救不了签名。
第二,C 组和 E 组证明增量保存确实能改东西。 C 组往文档里插入了新文字、文件增大了 915 字节,签名仍然校验通过——因为增量保存只在文件末尾追加一个更新段,原有字节一个都没动,[0, 45391) 这段的摘要自然还是对的。E 组同理,即使修改了页面对象,也是以"追加一个新版对象"的方式实现。
第三,D 组和 A 组表现不同但结果一样。 pypdf 的 PdfWriter.append() 重写后体积是 46496 字节(比原文件还大),/ByteRange 的偏移也没越界,但摘要不一致——因为它重新生成了文件内容,字节区间对不上了。
五、两个真实的开发坑
坑一:增量保存必须显式保持加密参数。
不加 encryption 参数直接增量保存,会直接抛异常:
pymupdf.mupdf.FzErrorArgument: code=4: Can't do incremental writes when changing encryption
修复方式是显式传 encryption=pymupdf.PDF_ENCRYPT_KEEP。另外注意,增量保存只能写回"打开时读取的那个文件",想保留原件要先 shutil.copy 一份再操作。
坑二:文件一旦被判定为 “repaired”,增量保存会被直接拒绝。
pymupdf.mupdf.FzErrorArgument: code=4: Can't do incremental writes on a repaired file
这个坑我在做本文实验时真实踩到了:第一版脚本用字节替换回填 /ByteRange,替换后的文本比占位符长了 29 字节,导致 xref 表里的偏移全部失效,MuPDF 加载时判定文件需要修复,随后 save(incremental=True) 被拒绝。教训是:任何字节级改动都必须保证长度不变,否则不只是签名失效,整个文件的可写性都会丢。
六、工程结论
给批量翻译流水线的处理策略:
- 翻译前先扫描签名。用第二节的
scan_signature(),检出/ByteRange或签名域就标记为"受保护文件"。 - 受保护文件一律走增量保存,并显式带上
encryption=pymupdf.PDF_ENCRYPT_KEEP,保存前先复制原件。 - 不要用完整重写处理带签名的 PDF,无论你有没有改内容——A 组实验已经证明这条路必坏。
- 要清楚增量保存的边界。增量保存保证的是"已签名的字节没被动过",签名本身仍成立;但用验签工具核对时,通常仍会提示"文档在签名之后被修改过",因为文件里追加了增量更新段。若文件带的是认证签名(
/SigFlags的 AppendOnly 位),那它只允许追加注释和表单填写,不允许修改正文——这种情况下正确的做法不是想办法绕过去,而是:翻译结果单独出一份文件,或者把改动交给签署方重新签。 - 签名文件建议单独出队列,与普通文件分开处理,避免被批处理脚本用默认姿势写坏。
参考资料
- PyMuPDF 官方文档:Document.save() 的 incremental 与 encryption 参数
- PDF 规范 ISO 32000-1 第 12.8 节(数字签名)、7.5.6 节(增量更新)
- pypdf 文档:PdfWriter.append / clone_document_from_reader
标签:PDF、Python、PDF翻译、技术避坑、文档处理
174



被折叠的 条评论
为什么被折叠?



