上一篇用合成样本验证了三类容易被混为一谈的风险:黑框只遮住视觉、不删除底层文字;增量保存可能保留旧修订;即使文字对象已经删除,特定工作流还可能存在字形位置侧信道。本篇不试图一次解决所有问题,而是先完成普通文本型 PDF 的最小、可复现闭环:
解析页面 → 搜索目标 → 检查矩形 → Mark → Apply
→ 非增量全量另存 → 独立解析与结构检查 → 决定是否发布
结论先行:Foxit PDF SDK 的 MarkRedactAnnot() 负责建立待脱敏区域,Redaction::Apply() 才负责应用标记、移除区域内的页面内容对象;生成文件之后,还要由独立实现进行验收。搜索成功不等于标记成功,标记成功不等于已经删除,保存成功也不等于可以发布。

先限定本篇处理范围
PDF 不只是几页文字。敏感信息还可能出现在扫描图像、OCR 文本层、表单字段、批注、附件、元数据、图层、JavaScript、签名覆盖的旧字节以及增量修订中。
本篇只处理一个可解释的基线场景:普通文本型、单页、无附件和表单的合成 PDF,目标值事先已知。 测试值包括合成姓名、手机号、证件号和薪资,共 4 项。扫描件、跨行与旋转页面、隐藏对象和对抗性验证分别留给后续文章。
这个限制很重要。只有先声明测试范围,最后的“通过”才有准确含义。
识别值与定位矩形是两个阶段
业务系统通常先在规范化文本或结构化数据中识别敏感值,再把逻辑目标映射为 PDF 页面上的一个或多个矩形。精确词典适合已知姓名、账号和项目代号;正则适合格式较稳定的手机号或证件号;固定字段坐标适合模板文档;OCR 则用于扫描件。
本篇示例的 –term 使用 Foxit TextSearch 的非正则字面量子串搜索,默认不区分大小写,也不要求完整单词匹配。它适合验证已知值,但不能冒充通用实体识别器。例如搜索 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_SN、FOXIT_KEY 环境变量读取授权值。本文使用的本地授权文件并不是“整文件即值”:序列号应取 gsdk_sn.txt 中 SN= 后的字符串,密钥应取 gsdk_key.txt 中 sign= 后的字符串。把字段名或说明文字一起传给 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 保证初始化与释放成对发生:
const std::string sn = ReadRequiredEnvironment("FOXIT_SN");
const std::string key = ReadRequiredEnvironment("FOXIT_KEY");
const ErrorCode result = Library::Initialize(sn.c_str(), key.c_str());
if (result != foxit::e_ErrSuccess) {
throw std::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");
TextPage text_page(page, TextPage::e_ParseTextNormal);
TextSearch search(text_page);
if (!search.SetSearchFlags(TextSearch::e_SearchNormal) ||
!search.SetPattern(term, false)) {
throw std::runtime_error("Cannot configure text search");
}
StartParse() 返回的是渐进式任务。示例持续调用 Continue(),直到 e_Finished;如果收到 e_Error,任务立即失败,不能把“已开始解析”当成“已完成解析”。
3. 每次命中都检查矩形和标记结果
while (search.FindNext()) {
RectFArray rectangles = search.GetMatchRects();
if (rectangles.GetSize() == 0) {
throw std::runtime_error("Search returned a match without rectangles");
}
const auto annotation = redaction.MarkRedactAnnot(page, rectangles);
if (annotation.IsEmpty()) {
throw std::runtime_error("Cannot create redaction annotation");
}
++match_count;
}
对本文 4 个已知必删值,任一目标在全文件零命中都会中止任务。批处理系统可以把规则区分为 required 和 optional,但不能默认把漏匹配当成成功。

4. Apply 后以固定策略另存
if (!redaction.Apply()) {
throw std::runtime_error("Redaction apply failed");
}
const foxit::uint32 save_flags =
PDFDoc::e_SaveFlagNoOriginal |
PDFDoc::e_SaveFlagRemoveRedundantObjects;
if (!document.SaveAs(output_path.c_str(), save_flags)) {
throw std::runtime_error("Cannot save output PDF");
}
本地 fs_redaction.h 对 Apply() 的说明是永久移除标记区域内的文本、图像和路径对象,并移除相应 redaction 注释。这里仍要把两件事分开:Apply() 处理当前有效页面对象;非增量全量另存则避免把原文件数据作为发布副本的基础。
示例固定组合 e_SaveFlagNoOriginal 与 e_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 / /Prev | 1 / 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 文本脱敏为何总漏字
网硕互联帮助中心





评论前必须登录!
注册