PDF 脱敏技术【2】:从识别到永久删除:用 Foxit PDF SDK C++ 构建可验证的 PDF 脱敏流程
2026/8/13 8:52:17 网站建设 项目流程

上一篇用合成样本验证了三类容易被混为一谈的风险:黑框只遮住视觉、不删除底层文字;增量保存可能保留旧修订;即使文字对象已经删除,特定工作流还可能存在字形位置侧信道。本篇不试图一次解决所有问题,而是先完成普通文本型 PDF 的最小、可复现闭环:

解析页面 → 搜索目标 → 检查矩形 → Mark → Apply → 非增量全量另存 → 独立解析与结构检查 → 决定是否发布

结论先行:Foxit PDF SDK 的MarkRedactAnnot()负责建立待脱敏区域,Redaction::Apply()才负责应用标记、移除区域内的页面内容对象;生成文件之后,还要由独立实现进行验收。搜索成功不等于标记成功,标记成功不等于已经删除,保存成功也不等于可以发布。

先限定本篇处理范围

PDF 不只是几页文字。敏感信息还可能出现在扫描图像、OCR 文本层、表单字段、批注、附件、元数据、图层、JavaScript、签名覆盖的旧字节以及增量修订中。

本篇只处理一个可解释的基线场景:普通文本型、单页、无附件和表单的合成 PDF,目标值事先已知。测试值包括合成姓名、手机号、证件号和薪资,共 4 项。扫描件、跨行与旋转页面、隐藏对象和对抗性验证分别留给后续文章。

这个限制很重要。只有先声明测试范围,最后的“通过”才有准确含义。

识别值与定位矩形是两个阶段

业务系统通常先在规范化文本或结构化数据中识别敏感值,再把逻辑目标映射为 PDF 页面上的一个或多个矩形。精确词典适合已知姓名、账号和项目代号;正则适合格式较稳定的手机号或证件号;固定字段坐标适合模板文档;OCR 则用于扫描件。

本篇示例的--term使用 FoxitTextSearch非正则字面量子串搜索,默认不区分大小写,也不要求完整单词匹配。它适合验证已知值,但不能冒充通用实体识别器。例如搜索Ann可能同时命中Anna;业务规则如果要求全词、大小写、规范化或正则匹配,应显式设置并准备相应测试。

另一个容易漏掉的点是:一次文本命中可能返回多个矩形。跨行文字、分散字符或复杂排版不能只取第一个矩形。

Foxit C++ 最小实现

本文使用 Foxit PDF SDK 11.1、Visual Studio 2022 x64 和 C++17。完整示例位于examples/foxit_cpp/redact_terms.cpp,由本文独立编写,不复制 SDK 包内示例代码,也不包含许可证内容。
我把SDK包放入到和我的工程同级目录“foxitpdfsdk_11_1_win”下,使用试用版本的key.

1. 安全读取许可证并初始化

示例程序只从当前进程的FOXIT_SNFOXIT_KEY环境变量读取授权值。本文使用的本地授权文件并不是“整文件即值”:序列号应取gsdk_sn.txtSN=后的字符串,密钥应取gsdk_key.txtsign=后的字符串。把字段名或说明文字一起传给Library::Initialize()会得到无效许可证错误。

下面的 PowerShell 只注入解析后的字段,不打印它们:

$snRaw=Get-Content.\foxitpdfsdk_11_1_win\lib\gsdk_sn.txt-Raw$keyRaw=Get-Content.\foxitpdfsdk_11_1_win\lib\gsdk_key.txt-Raw$env:FOXIT_SN =[regex]::Match($snRaw,'(?im)^\s*SN\s*=\s*(\S+)\s*$').Groups[1].Value$env:FOXIT_KEY =[regex]::Match($keyRaw,'(?im)^\s*sign\s*=\s*(\S+)\s*$').Groups[1].Value

生产环境还应检查匹配是否成功,并在进程结束时清除变量。许可证不能写进源码、截图、构建日志或代码仓库。C++ 中用 RAII 保证初始化与释放成对发生:

conststd::string sn=ReadRequiredEnvironment("FOXIT_SN");conststd::string key=ReadRequiredEnvironment("FOXIT_KEY");constErrorCode result=Library::Initialize(sn.c_str(),key.c_str());if(result!=foxit::e_ErrSuccess){throwstd::runtime_error("Foxit PDF SDK initialization failed; error_code="+std::to_string(static_cast<int>(result)));}

日志只记录阶段和错误码,不记录授权值或目标原文。

2. 解析页面并搜索目标

PDFPage page=document.GetPage(page_index);FinishProgress(page.StartParse(PDFPage::e_ParsePageNormal,nullptr,false),"Page parsing");TextPagetext_page(page,TextPage::e_ParseTextNormal);TextSearchsearch(text_page);if(!search.SetSearchFlags(TextSearch::e_SearchNormal)||!search.SetPattern(term,false)){throwstd::runtime_error("Cannot configure text search");}

StartParse()返回的是渐进式任务。示例持续调用Continue(),直到e_Finished;如果收到e_Error,任务立即失败,不能把“已开始解析”当成“已完成解析”。

3. 每次命中都检查矩形和标记结果

while(search.FindNext()){RectFArray rectangles=search.GetMatchRects();if(rectangles.GetSize()==0){throwstd::runtime_error("Search returned a match without rectangles");}constautoannotation=redaction.MarkRedactAnnot(page,rectangles);if(annotation.IsEmpty()){throwstd::runtime_error("Cannot create redaction annotation");}++match_count;}

对本文 4 个已知必删值,任一目标在全文件零命中都会中止任务。批处理系统可以把规则区分为requiredoptional,但不能默认把漏匹配当成成功。

4. Apply 后以固定策略另存

if(!redaction.Apply()){throwstd::runtime_error("Redaction apply failed");}constfoxit::uint32 save_flags=PDFDoc::e_SaveFlagNoOriginal|PDFDoc::e_SaveFlagRemoveRedundantObjects;if(!document.SaveAs(output_path.c_str(),save_flags)){throwstd::runtime_error("Cannot save output PDF");}

本地fs_redaction.hApply()的说明是永久移除标记区域内的文本、图像和路径对象,并移除相应 redaction 注释。这里仍要把两件事分开:Apply()处理当前有效页面对象;非增量全量另存则避免把原文件数据作为发布副本的基础。

示例固定组合e_SaveFlagNoOriginale_SaveFlagRemoveRedundantObjects,不加入e_SaveFlagIncremental,同时拒绝覆盖已存在的输出文件。输入路径与输出路径不同只是原件保护措施,不能单独证明发生了全量重写。

/Prev、多组startxref/%%EOF等属于本文采用的保守修订结构指示器。发现这些指示器时,发布准入检查不通过;未发现这些指示器,不等于已经完成所有历史内容的取证证明。

用真实 Foxit 输出做一次完整实验

运行参数覆盖合成样本中的 4 个目标:

.\redact_terms.exe `.\pdf_redaction_test_original.pdf `.\output\pdf\pdf-redaction-test-foxit-redacted.pdf `--term'Zhang San'`--term'13812345678'`--term'350102199001011234'`--term'CNY 35,000'

Foxit 处理阶段返回matches=4。输出使用新路径,原件未被覆盖。重新打开并渲染后,4 个字段位置均显示为黑色脱敏区域:

视觉正确仍然只是第一层证据。随后使用仓库中的 pypdf 验证器:

python tools/verify_pdf_redaction.py ` output/pdf/pdf-redaction-test-foxit-redacted.pdf `--denylist assets/data/known-sensitive-values.txt `--expected-pages 1 `--fail-on-attachments `--fail-on-forms `--fail-on-active-content `--fail-on-incremental-updates `--json qa/foxit-output-verification.json

为了减少“一个解析器漏掉、另一个解析器也没检查”的风险,又使用基于 pdfminer 的pdfplumber做第二次文本提取。结果如下:

检查项实际结果
Foxit 搜索并标记的目标4
pypdf 拒绝列表命中0
pdfminer 拒绝列表命中0
startxref / %%EOF / /Prev1 / 1 / 0
检测到增量更新false
附件 / 表单字段 / 活动内容线索0 / 0 / 0

这次实验支持的准确结论是:在本文合成样本和 4 个已知目标值的测试范围内,Foxit 输出通过视觉、两种独立文本提取、修订结构和重新打开检查。

验证器同时给出两项提醒:文件仍有文档元数据,且存在可选内容属性——本样本包含 Foxit SDK 评估水印。它们没有命中本文拒绝列表,但仍应根据发布白名单复核。字形位置侧信道本次没有实现攻击模型,状态应记录为not_tested,不能默认判定通过。

把失败变成可处理状态

示例把以下情况全部视为失败并返回非零状态:

  • 授权字段缺失、格式错误或模块权限不足;
  • 输入不存在、输出已存在或两个路径相同;
  • 文档加载、页面解析或搜索配置失败;
  • 任一必删目标零命中;
  • 命中没有矩形,或 redaction 注释创建失败;
  • Apply()SaveAs()失败;
  • 独立验证命中拒绝列表或违反必选发布准入规则。

批处理服务收到失败后应保留原件、隔离候选输出并记录不含敏感原文的原因,不能继续把部分结果送入发布目录。

为什么使用 SDK,而不是自己改内容流

PDF 页面内容涉及字体子集、字符编码、文本矩阵、裁剪、透明度、共享 XObject、压缩流和异常文件。自己改写内容流看似减少依赖,进入复杂文档后,成本会转移到格式兼容、崩溃恢复和长期回归测试。

Foxit 的公开资料说明 Windows SDK 提供多语言 API,并支持桌面和服务端集成,Redaction add-on 用于移除文本、图形和图像。SDK 可以作为处理引擎,但数据识别、人审、发布准入规则、审计和独立验证仍属于完整系统职责。选型时应使用自己的合成样本与风险矩阵做 POC,不能只根据功能列表下结论。

下一篇将处理文本型 PDF
最常见的漏删原因:文字被拆成多个对象、姓名跨行、页面旋转以及页面坐标和字形边界不一致。复杂规则不应继续塞进本篇的最小闭环;第 3 篇会单独建立“规范化文本 → 原始字符索引 → 多个页面矩形”的字符级回映流程,并用相邻控制文字检查误删。


本文基于公开资料和个人技术验证,不代表福昕官方观点;法规内容仅作技术背景,不构成法律意见。

评论或私信“PDF脱敏清单”,获取完整示例代码。如果你需要自己把上述流程放进自己的 Windows 应用或服务端 POC,可以查看 Foxit PDF SDK 的试用入口和 Redaction add-on 相关API说明。

专栏导航:上一篇:PDF 脱敏技术【1】:PDF 脱敏不是盖黑框:为什么敏感信息仍能被复制,正确的保护方式是什么?|系列目录:PDF 脱敏技术系列|下一篇:[PDF 脱敏技术【3】:PDF 文本脱敏为何总漏字

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询