PDF 批注到底存在文件的哪里?从 /Annots 讲到导出验证
起因:有人问"我在 PDF 上批注了,删掉之后对方还能看见吗"。这个问题看着简单,往下追会发现涉及 PDF 的对象模型、增量更新和验证链路。这篇把我们踩过的坑整理一遍。
一、批注不是"画上去"的
很多人(包括一些工具的实现者)默认把批注理解成"在页面上叠一层东西"。这个理解在操作层面没错,但在文件层面是错的 :批注是 PDF 对象树里的独立对象,通过引用挂在页面上。
页面字典长这样:
css
4 0 obj
<< /Type /Page
/Parent 2 0 R
/MediaBox [0 0 595 842]
/Contents 5 0 R
/Annots [10 0 R 11 0 R 12 0 R] % ← 批注在这里
>>
endobj
/Annots 是一个引用数组,指向真正的批注对象:
less
10 0 obj
<< /Type /Annot
/Subtype /Text % 便签批注
/Rect [100 700 120 720] % 位置
/Contents (这里的价格需要重新确认)
/T (zhangsan@company.com) % 作者
/M (D:20261024153000+08'00')% 修改时间
/C [1 1 0] % 颜色
/Popup 13 0 R % 弹出框(附属对象)
>>
endobj
这里有三个字段值得单独拎出来,因为它们是实际事故的高发点:
/T------ 作者。你用什么账号登录的编辑器,这里就写什么,通常是邮箱。/M------ 修改时间,精确到秒。/Popup------ 弹出框对象。这是个独立的引用,不是批注的内嵌字段。
也就是说:你删了主批注对象,Popup 还挂在文件里。 这是很多人"删不干净"的第一个原因。
二、Subtype 决定处理方式
批注的类型由 /Subtype 决定,常见几种:
| Subtype | 含义 | 处理方式差别 |
|---|---|---|
/Text |
便签 | 删对象即可 |
/Highlight |
高亮 | 只删批注,正文不受影响 |
/FreeText |
自由文本 | 看起来像正文,实际是浮层 |
/Square /Ink |
图形、涂鸦 | 删对象即可 |
/Widget |
表单域 | 删了表单就填不了,要谨慎 |
/Watermark |
水印注释 | 只能用对象层删除,白块盖不住 |
/Stamp |
图章 | 通常带外观流(AP) |
/FreeText 是最大的认知陷阱。用户在上面打字,屏幕上确实显示了新文字,但底下的原文一个字节都没变。对方用选择工具框一下,原文原样复制出来。
我们处理过的"合同改了数字对方说没改"的case,绝大多数是这一个原因。
三、删除批注的三个坑
坑 1:手工改字节 → 文件损坏
最直觉的做法是:找到 /Annots 数组,把里面的内容删掉,写回文件。
然后文件打不开了。原因:PDF 的交叉引用表(xref)记录了每个对象的字节偏移量。你删了字节,后面所有对象的偏移量全变了。
正确做法:用 PDF 库在对象层操作,让库去维护 xref。
用 pdf-lib 举例:
js
const { PDFDocument, PDFName, PDFArray } = require('pdf-lib');
const fs = require('fs');
const bytes = fs.readFileSync('in.pdf');
const pdfDoc = await PDFDocument.load(bytes);
for (const page of pdfDoc.getPages()) {
// 清空该页的批注引用数组
page.node.set(PDFName.of('Annots'), pdfDoc.context.obj([]));
}
// Popup 是独立对象,也要单独清理
const pages = pdfDoc.getPages();
for (const page of pages) {
const annots = page.node.lookupMaybe(PDFName.of('Annots'), PDFArray);
if (annots) { /* 处理 */ }
}
const out = await pdfDoc.save();
fs.writeFileSync('out.pdf', out);
坑 2:增量更新让"删除"变成"追加"
PDF 支持增量更新:修改文件时不重写全文,而是在文件尾部追加 一个新的 xref 段,用 /Prev 指回旧的。
后果是:你"删除"的对象,字节还完整地躺在文件里。
验证方法:
bash
# 展开所有对象流,变成可读形式
qpdf --qdf --object-streams=disable in.pdf expanded.pdf
# 直接搜
strings in.pdf | grep -i "批注里的关键词"
用 pdftotext 抽不到,是因为它按最终状态解析。但文件字节里还在------对方只要肯动手,就能拿到。
彻底解决:做一次完整重写,并且清理未引用对象:
bash
# mupdf
mutool clean -g -s -d in.pdf out.pdf
# qpdf
qpdf --linearize in.pdf out.pdf
-g 是垃圾回收(清理未引用对象),-s 清理未使用的资源,-d 解压所有流。
坑 3:靠数量判断"已清除"
我们早期版本会显示"已清除 3 处批注"。后来发现有些根本没删掉------因为统计的是"尝试删除的数量",不是"实际删除的数量"。
现在的做法:清理完必须重新解析一次文件,验证批注层确实为空,才报成功。
这条是我觉得最该做、但行业里普遍没做的事。
四、完整的检测清单
如果要做文件层的批注/隐私检测,这八项是必要的:
| 检测项 | 技术要点 |
|---|---|
| 文字水印 | 内容流扫描;中文走 Identity-H CID + ToUnicode CMap 语义层还原 |
| 图片水印 | 递归 Form XObject + cm 矩阵复合映射 |
| 注释水印 | /Subtype /Watermark,只能对象层删 |
| 元数据 | 作者 / 公司 / 路径 / 软件 / 创建修改时间 |
| 隐藏文字 | 白字、被遮挡、图层下 |
| 批注与修订 | /Annots 数组 + /Popup + 增量更新段 |
| 隐私信息 | 身份证号 / 手机号 / 银行账号(含图片内 OCR) |
| 暗水印 | 频域 / 像素级 |
五、一句话总结
PDF 批注的坑,本质都是同一个:界面上看到的状态,和文件里实际存在的字节,是两回事。
隐藏批注 ≠ 删除批注,增量更新里的旧对象 ≠ 不存在。所以不管用什么工具,处理完自己验证一次:
bash
pdftotext 文件.pdf - | grep -i "关键词"
strings 文件.pdf | grep -i "关键词"
十秒钟的事。
我们在做 PDFnoted(pdfnoted.com),方向是文档安全工作流(扫描 → 脱敏 → 批注 → 导出)。上面这些坑基本都是自己踩出来的,其中 Form XObject 递归 + CTM 复合映射那条折腾最久。元数据清理、批注清理、文字水印清理目前是免费且免登录的。