前段时间开发了 PDF 涂黑/脱敏功能:框选页面上的任意区域(或者按身份证号、手机号、邮箱批量匹配),生成的 PDF 里这部分内容被物理删除——用任何工具复制、搜索、提取都找不回来。
做这个功能的初衷很简单:市面上大部分"PDF 遮盖"工具是在文字上面画一个黑框,底下文字原封不动,谁都能一键选中复制出来。这种"假涂黑"在涉密场景里等于裸奔。所以这个功能的核心目标从第一天起就很明确:涂掉的内容必须真正从文件里消失。
这篇文章记录开发过程中三个最有意思的部分:真删除的架构选型、一个折磨了我三天的"涂黑位置不对"的坐标系 bug、以及上线前的两轮对抗式审查挖出来的隐私泄露渠道。
一、架构:为什么选 PyMuPDF,以及"双引擎验证"
真删除在 PDF 领域有个相对成熟的做法:redaction annotation。流程是把要删的区域先做成Redact注释,然后执行apply_redactions,PDF 引擎会重写页面的内容流,把区域内的文字绘制指令真正删掉,再用黑块填充。
引擎选型上我用了PyMuPDF(MuPDF 的 Python 绑定)。这里有个开放源代码的坑要先交代:PyMuPDF 是 AGPL 协议。如果闭源商用在法律上有义务开源,这个决策需要业务方拍板,我这里不多展开。我的场景可以接受 AGPL,就用了它——一个现成的、久经考验的 redaction 实现,比自己用 pikepdf 手写内容流手术靠谱得多。
但选了 PyMuPDF 做"手术",验证就不能再用它自己。这是安全工具设计里我很在意的一条:同一引擎既做删除又做验证,等于让运动员兼裁判——引擎本身的解析 bug 或 redaction 的实现缺陷,两边会互相掩盖。所以验证通道用了两个和 MuPDF 毫无关系的引擎:
- pdfminer.six:纯 Python 的文本提取,逐字符定位;
- pypdfium2:PDFium(Chrome 内核)的绑定,处理逻辑完全独立。
删除完成后,两个引擎分别提取全文,断言目标文本不存在。任何一个引擎还能提取到残留,就触发光栅化降级:把残留页整页转成图片重新插入,图片上的文字天然不存在。降级之后再验一次,还不行就直接报错拒绝对外提供文件——宁可失败,不可泄露(fail closed)。
另外几个架构细节:
- 扫描件/图片页没有文字层,框选后走"整页光栅化 + 像素画黑块"路径,原图文字本来就不存在,天然真删除;
- **注释(Annotation)**里藏的文字也要处理,这个在后面的对抗审查里展开;
- 限制:单文件 20MB、100 页以内,超了提示拆分。这不是偷懒——服务器是小水管,大文件的光栅化降级会把内存和超时都顶爆。
二、坐标镜像 bug:测试全绿,用户说涂黑位置全错
功能上线后的第一版,本地测试全绿:36 个用例覆盖中英文删除、正则定位、注释处理、旋转页、加密拒绝、降级路径。端到端脚本也过了。然后有真实用户在真实文件上反馈:框选涂黑的位置不对。
用户的描述很具体:先框第 2 行文字,再框第 5 到第 10 行,生成的 PDF 里,第一处的黑块跑到了 5~10 行附近,第二处的黑块跑到了第 2 行附近——“不完全是前后交换”。这句话成了后面破案的关键线索。
第一轮排查:两个都是错误答案
第一次修复,我在前端 pdf.js 的渲染流程上找到了一个真实存在的竞态:pdf.js 渲染是异步的,用户缩放或翻页后立即框选,坐标换算用的还是旧视口比例。这个确实要修(渲染中禁止框选、提交前校验视口与画布尺寸一致),但它解释不了用户的场景——用户没缩放,顺序框两个框就错了。
第二次,我把怀疑点放在 CropBox 上。带偏移原点的 CropBox 页面,pdf.js 和 PyMuPDF 的坐标基准确实不同。我构造了带 CropBox 偏移的样本,实验、修复、加了回归用例,看起来闭环了。请人家再测:还是错的。
转折:让用户提供出问题的原始文件
这次我直接请用户把出问题的 PDF 发过来(就是文章开头说的那个 32 页的 ABAP 代码清单)。拿到文件第一件事:体检。MediaBox 标准 A4、无旋转、CropBox 无偏移——前两次修复的前提在这个文件上根本不成立。
然后我用 PyMuPDF 把两处目标文本的坐标搜出来,对着渲染图一对,发现了诡异的现象:文本提取报告某行在页面底部,渲染图里它明明在顶部。同一份文件,提取坐标和渲染坐标对不上。
沿着这条线往下挖,先后用内容流分析、PDFium 交叉验证,最后把整个证据链拼起来:
- 这个 PDF 的内容流完全正常:没有变换矩阵,第一行文字的
Tm坐标就是页顶; - 渲染正常:MuPDF 自己渲染出来的 PNG 和 Adobe、Chrome 显示一致;
- PyMuPDF 的文本提取 API(get_text / search_for)返回的是"左上原点、y 向下"的视觉坐标系——它内部已经做了 PDF 原生坐标(左下原点、y 向上)到视觉坐标的转换;
- 而前端 pdf.js 的
convertToPdfPoint返回的是PDF 原生坐标(y 向上); - 后端把前端送来的 y 值直接塞给 PyMuPDF——按 y 向下解释。两个坐标系差一个
y → 页高 - y的变换,整个页面绕中心垂直镜像。
用镜像公式验证用户的观察:第 2 行(视觉上距顶约 152pt)镜像后落在距顶 690pt 的位置,正好是 CONSTANTS 块里 c_p/c_q 一带;用户框的 CONSTANTS 块(距顶 433~756pt)镜像后落在 86~409pt,正好罩住 CLASS 行附近。和用户说的"近似互换但不是交换"分毫不差——因为镜像映射不是两框交换,是全页翻转。
为什么 36 个测试全绿?
这是这次事故里最值得反思的部分。测试不是没测框选模式——黄金用例断言了"框内文字删除、框外保留"。但用例里喂给后端的坐标,是我用 PyMuPDF 的 search_for 拿到的文本坐标,直接当作"前端会发来的坐标"。而 search_for 返回的恰好就是后端期望的坐标系——测试输入和被测实现共享了同一个坐标系假设,镜像是自洽的,测试自然全绿。
只有浏览器路径(pdf.js convertToPdfPoint)会产生 y 向上的坐标,而测试里没有任何一个用例从"人在屏幕上框选"这个物理动作出发独立推导坐标。
修复本身反而简单:
// 前端:css 像素直接除以视口缩放,得到的就是"左上原点、y 向下"的视觉坐标constscale=viewport.scale;constx=Math.min(s.x,p.x)/scale;consty=Math.min(s.y,p.y)/scale;前端送视觉坐标(css 像素 / viewport.scale),后端 PyMuPDF 原样使用——两个 API 体系从此在同一个坐标系里说话。这个约定对旋转页(/Rotate)也天然成立,因为 pdf.js 视口和 PyMuPDF 的提取坐标都已经是"旋转后的视觉空间"。
修复后还顺手清了一笔技术债:光栅化降级路径里也藏着一份同样的 y-up 假设,意味着关键词模式的降级黑块同样会镜像。一起修了。
教训总结成两条:
- 跨坐标系的工具链(pdf.js / PyMuPDF / Adobe),集成测试的输入坐标必须从"用户的物理动作"独立推导,绝不能用被测系统自己的 API 生成测试输入——那是在用实现验证实现;
- 用户报告的 bug 复现不了时,尽早要到出问题的原始文件。我第一次修复是靠猜,第二次是靠一个"理论上存在"的场景,第三次靠真实文件半小时锁定。前两次的修复反而污染了代码(CropBox 校正后来证实是错误理论,回滚)。
三、上线前的对抗式审查:两个泄露渠道,都不是文本层的
功能能用了之后,我换了个角色审视它:假设我是个攻击者,涂黑过的文件里还有哪些地方能挖出信息?
渠道一:书签(Outline)。Word 导出的 PDF 经常把标题写进书签。用户涂黑正文里的机密段落,如果那段文字同时是书签标题,生成的 PDF 书签面板里原文一字不差。文本验证通道根本不会去看书签。修复:涂黑后扫描书签树,标题与被删内容做"剥空白 + 忽略大小写"的双向包含匹配,命中即删。这里有个细节——书签是带层级的树,删掉一个父节点后,子节点的层级会断档,重写书签时要做层级紧凑化,否则 set_toc 直接报错。
渠道二:注释的 Contents。PDF 注释种类很多:便签(Text)、矩形(Square)、高亮(Highlight)……每种注释都可以带一段/Contents文本。用户用便签工具批注过敏感内容,或者用矩形框圈过文字写了备注——这些内容和被涂黑区域相交时必须一起删。原实现只删了 FreeText 和 Stamp 两类,其余类型全部漏网,而且注释文本不进文本验证通道,删没删成功验证引擎根本不知道。修复:相交即删,不分类型(表单字段除外,只警告不删)。
还有几个输入侧的加固:坐标参数拒绝 NaN/Infinity(JSON 允许这两个字面量直达后端);框选页码超出文件页数时必须报错——原实现会静默返回没涂黑的原文件,用户以为脱敏了其实什么都没发生,这是最危险的一类 bug;双引擎验证的文本匹配做了空白归一化,防止两个引擎的换行/空格差异把"删干净了"误判成"有残留"从而触发不必要的整页降级。
这些修复全部配了对应用例,涂黑测试集从 36 个涨到 46 个。
四、写在最后
这个功能现在在 iPDFToo上可以免费用:支持框选涂黑(桌面端)、身份证号/手机号/邮箱一键批量定位,输出报告里明确标注哪些内容被删除、哪些页触发了光栅化降级,全程文件临时存储定期清理。
回看整个开发过程,有三点感受比较深:
- 安全功能的验证通道和执行通道必须是两套独立实现,这不是洁癖,是这次事故里"测试全绿但线上全错"能发生的结构性原因之一;
- 坐标系统的坑,本质上是"约定"的坑。PDF 原生坐标、渲染坐标、CSS 像素坐标三套体系,谁做转换、转换几次,必须有一份写下来的约定,而不是散落在各处的心照不宣;
- 用户反馈是最贵的测试资源——那个"不完全是前后交换"的细节描述,价值超过我写的所有测试用例。尽早要原始文件,别靠猜。
目前还有两个坑还没来得及填,先记在这里:PDF 附件(embedded file)里如果带了原始文档,涂黑正文并不能保护它;表单字段(Widget)与涂黑区域相交时目前只警告不处理。这两个都排进了下个版本。