PDF 批注到底存在文件的哪里?从 /Annots 讲到导出验证

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 复合映射那条折腾最久。元数据清理、批注清理、文字水印清理目前是免费且免登录的。

相关推荐
MasonYyp1 天前
安装npm依赖仓库Verdaccio
前端·npm·node.js
编程的一拳超人2 天前
【元宝搜索开发者征文大赛】四语实战:Java/Python/Go/Node.js 构建 AI 搜索应用全攻略
java·python·golang·node.js·agent
lucas_AI2 天前
今年 2 月就修好的漏洞,10 月才炸:HTTP2Shell 把 Node、Python 一锅端
node.js
SEO_juper3 天前
Node.js 全站死链检测工具:从 sitemap 出发一次找全 404
开发语言·人工智能·python·node.js·seo·外贸独立站
孟陬4 天前
解决动态 import 类型 Any 痼癖
typescript·node.js·bun
Dumbness4 天前
后台管理员把自己锁在门外:RBAC 里「自己的权限只能由别人来减」
安全·node.js
攻城狮白玉4 天前
Node.js 发布紧急安全更新:10 个漏洞,8 枚危险证书被拉黑。附AI修复提示词
node.js
Json____5 天前
用 FastAPI + Vue3 打造一套完整的在线考试系统:从组卷到阅卷的业务闭环实践
后端·node.js·fastapi·it学习·wwwoop.com
Dumbness5 天前
写 MCP 服务器踩的坑:stdout 是协议通道,一行不带换行的日志就能让客户端超时
node.js·mcp