PDF 删掉内容后文件反而变大?增量更新机制一次讲清
在做 PDF 内容清理时,最容易让人困惑的一个现象是:你删掉了东西,文件却变大了。
第一次遇到会觉得是工具写坏了。实际上这是 PDF 格式本身的设计导致的,而且如果不理解它,你的"清理"很可能只是自欺欺人。
一、PDF 的文件结构不是线性的
一个典型的最小 PDF 长这样:
%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /Contents 4 0 R >> endobj
4 0 obj << /Length 55 >> stream ... endstream endobj
trailer << /Root 1 0 R >>
startxref
1234
%%EOF
关键是 startxref 和它指向的 xref 表——交叉引用表,记录每个对象号在文件中的字节偏移。读取器打开文件,从尾部找到 startxref,读 xref,再按偏移量随机访问各个对象。
注意这个结构:对象在文件中的物理位置,和它的逻辑顺序无关。对象 10 可以写在对象 3 前面。
二、增量更新:只追加,不修改
PDF 规范允许一种更新方式:不修改原文件,只在文件末尾追加新的对象、新的 xref 和新的 trailer。
[原始文件]
trailer << /Root 1 0 R /Prev 1234 >> ← 多了 /Prev
startxref
5678
%%EOF
新的 trailer 里有 /Prev,指向上一个 xref 的位置。读取器从后往前解析:读最新的 xref,遇到 /Prev 再往前读一份,把两份合并,新版本覆盖旧版本。
带来的后果:
- 被"删除"的内容其实还在。 删除一个对象不是把字节抹掉,而是在新 xref 里把它标成 free,或者在新的页面对象里不再引用它。原始字节原封不动躺在文件里。
- 文件大小只会增长。 你删一百次,文件末尾就多一百段追加记录。
- 整个修改历史是可还原的。 顺着
/Prev链一路往前走,每一版的内容都能读出来。
这就是为什么"清理完反而变大"——你在末尾追加了新的内容流,旧的那份没动。
三、验证方法
别相信工具告诉你"已清除",自己验证:
# 1. 看有几个 %%EOF,大于 1 说明有过增量更新
grep -c "%%EOF" file.pdf
# 2. 被删掉的词是否还能搜出来
pdftotext file.pdf - | grep -i "你删掉的关键词"
# 3. 看 /Prev 链
strings file.pdf | grep -n "Prev"
第 2 条是最直接的。如果搜得到,说明内容还在物理文件里,只是不再被渲染。
四、正确的处理方式
4.1 全量重写
真正要清除,必须重新序列化整个文件:解析所有对象,丢掉不再被引用的,按新顺序写出,生成全新的 xref。
用 qpdf:
qpdf --qdf --object-streams=disable in.pdf expanded.pdf # 展开,便于检查
qpdf --linearize in.pdf out.pdf # 重写(会保留部分历史)
注意 qpdf 默认也是保守的。--linearize 会重写,但要确认结果里不再有多个 %%EOF。
4.2 自己实现时的三个要点
要点一:不要复用旧的对象容器。
常见做法是在原 PDF 库里拿到对象、改完再 save()。这个 save 多半是增量写入。必须走"新建文档 → 逐个迁移对象 → 保存"的路径。
要点二:清理关联字典键。
重写内容流对象时,除了替换 stream 数据,还要把这些键从字典里删掉:
/Filter /DecodeParms /F /FFilter /FDecodeParms /DL
只改数据不改 /Filter,读取器会按旧的压缩方式去解压,直接报文件损坏。
要点三:对象流(Object Stream)要单独处理。
PDF 1.5 之后支持把多个对象打包进一个压缩流(/Type /ObjStm)。此时单个对象在 xref 里不是独立偏移,而是"在第 N 号对象流的第 i 个位置"。重写时必须展开对象流,否则你以为删掉的对象还躺在压缩包里。
五、孤儿对象:不被引用但仍在文件里
和增量更新相关但不同的另一个概念。一个对象如果没有任何地方引用它,它就是孤儿。孤儿不会渲染,不会显示,但:
- 它占用文件体积
- 它的内容可以被
pdftotext之外的工具提取出来
检测孤儿的办法是做一次可达性分析:从 trailer 的 /Root 出发,递归遍历所有引用,标记可达对象;剩下的就是孤儿。
# 思路示意
reachable = set()
def walk(ref):
if ref in reachable: return
reachable.add(ref)
obj = resolve(ref)
for child in obj.refs(): # 递归所有间接引用
walk(child)
walk(trailer['/Root'])
orphans = all_objects - reachable
遍历时别漏掉这几处:/Pages 的 /Kids、/Resources 里的字体和 XObject、/Annots、以及 Form XObject 内部引用的资源。漏掉 Form 的递归会误判大量对象为孤儿,删了就白屏。
六、一个实操建议
如果你在写 PDF 处理代码,建议把这三件事做成固定的回归测试:
- 清理后重新解析文件,确认能打开(很多 bug 在这一步才暴露)
- 用
pdftotext+grep验证目标内容确实消失 - 检查
%%EOF数量,确认没有留下增量更新链
第三点最容易被忽略,但它恰恰是"看起来清理了、实际上没有"的根源。
小结:PDF 的"删除"默认是逻辑删除。要物理删除,必须全量重写 + 展开对象流 + 递归 Form XObject 做可达性分析,缺一步都会留下残留。
顺手能做的检查
不想记这些细节也没关系,只要记住一条:判断有没有真的删干净,不要看文件大小,也不要相信工具给的"已清除"提示。
用两条命令就能自己核:
pdftotext 文件.pdf - | grep -i "你确定删掉的关键词"
pdfimages -list 文件.pdf
第一条搜文字残留,第二条看还有没有没被引用的图片对象。两条都干净,才算真的清掉了。
我参与的 PDFnoted(pdfnoted.com)做的就是这个环节的自动化:清理完之后会重新解析一遍文件来确认,而不是按"处理了几个对象"直接报成功——这也是判断一个 PDF 清理工具靠不靠谱最直接的分水岭。
浙公网安备 33010602011771号