有人在一个名字上画了个黑框,什么都没扁平化,就把文件发了出去,审阅者选中那个矩形,把名字粘贴进一封邮件。PDFiumPas 用操作符级涂黑回应这种事:SaveAsRedacted 只删除字符框与涂黑矩形相交的 Unicode 标量,按原字体、字号、矩阵、渲染模式和颜色重建幸存者,并裁剪轴对齐的路径和图像,而不是整坨丢弃
为什么画上去的矩形不是涂黑
加在内容流之上的绘制操作什么都藏不住,因为它底下的文字显示操作符仍在流里、仍映射到码点。ISO 32000-1 §9.4 把文本对象定义为 BT 和 ET 之内一串定位与显示操作符;之后画的填充矩形只是同一流里的另一个操作符。提取遍历的是操作符,不是像素,所以被盖住的字符串完好回来。真正的涂黑必须移除操作数,而不是遮挡输出
显而易见的安全实现很粗暴:找出每个边界框与涂黑矩形相交的页面对象,删掉整个对象。早期 PDFiumPas 版本就是这么做的,它正确但昂贵。单个 Tj 可以携带一整行表格,所以涂掉一个账号会把日期、摘要和金额一起带走。一个恰好是整宽表格条带的矩形填充会在整页范围内消失。一张发票 logo 消失,只因为涂黑削掉了它的一个角。3.101.0 版把决策下移一层,从页面对象到操作数
操作符级涂黑到底删什么?
PDFiumPas 删除 Unicode 标量,不是文本对象。SaveAsRedacted 期间,组件从加载的文本页建立字符到页面对象的映射,然后对受测对象拥有的每个字符读取字符框,并把那个框与每个涂黑矩形求交。碰到矩形的字符标记移除;其余标记为幸存者。如果没有任何相交,对象完全不被碰。如果每个字符都相交,对象整体移除,与以前完全一样。只有混合情形触发拆分
然后每个幸存者作为自己的文本对象重新发出,用原字体句柄、原字号、逐字符文本矩阵、原文本渲染模式,以及父对象的填充和描边状态(含描边宽度、线连接、线帽和虚线数组)构建。复用字体句柄而不是解析新句柄,是字形度量保持一致的原因;复用逐字符矩阵,是字偶距和词间距无需重跑排版就各就各位的原因。代价是对象数:一个保留字符变成一个文本对象,这就是 TPdfRedactionOptions.MaxSplitObjects 作为生成片段硬上限存在的原因
procedure RedactDocument(const SourcePdf, TargetPdf: string);
var
Pdf: TPdf;
Options: TPdfRedactionOptions;
Report: TPdfRedactionReport;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := SourcePdf; // 文件已带 /Redact 注释
Pdf.Active := True;
Options := TPdfRedactionOptions.Default;
Options.PreservePartialObjects := True; // 操作符级拆分(默认)
Options.RemoveIntersectingAnnotations := True;
Options.MaxSplitObjects := 20000; // 生成片段的上限
if not Pdf.SaveAsRedacted(TargetPdf, Options, Report) then
raise Exception.Create(Report.ErrorMessage); // 失败即拒,不可交付
finally
Pdf.Free;
end;
end;
矩形裁剪,旋转几何不裁剪
路径只有在 PDFiumPas 能证明它是轴对齐矩形时才拆分。证明刻意很窄:对象矩阵的两个剪切项都必须低于 0.0001,路径必须由四到六个段组成,以 MOVETO 开头且后续只有 LINETO,变换后的点必须在 0.01 容差内落在对象边界的四个角上。通过这道检查的路径用连续矩形减法归约,每个涂黑矩形把幸存集切成左、右、下、上条带,每个结果条带用原填充模式、描边标志和绘制状态重建。曲线、三角形、被裁剪形状以及任何旋转的东西都过不了检查,整个对象被移除
图像遵循 ISO 32000-1 §8.9,其中图像样本占据经当前变换矩阵映射的单位正方形。PDFiumPas 反转那个映射,把每个幸存的页面空间片段变回归一化图像坐标,夹到单位区间,然后通过向内舍入转换成像素索引:左边和上边走 Ceil,右边和下边走 Floor。方向很要紧。向外舍入会让涂黑侧的一整列部分源像素在片段边缘幸存。整数像素边界然后被转换回归一化坐标,并用于推导片段矩阵,所以裁剪后的位图恰好落在它被切开的像素边界上。裁剪本身是跨 Gray、BGR、BGRx 和 BGRA 格式的感知步长行复制。与路径一样,旋转或倾斜的图像、或矩阵带退化缩放项的图像,被整体移除
// 一次成功的 SaveAsRedacted 调用之后
Writeln(Format('applied %d redaction(s) on %d page(s)',
[Report.RedactionCount, Report.RedactedPageCount]));
Writeln(Format('scanned %d object(s), removed %d',
[Report.ScannedObjectCount, Report.RemovedObjectCount]));
Writeln(Format('split text/path/image: %d / %d / %d',
[Report.SplitTextObjectCount, Report.SplitPathObjectCount,
Report.SplitImageObjectCount]));
Writeln(Format('preserved %d fragment(s)', [Report.PreservedFragmentCount]));
Writeln(Format('pruned %d resource name(s), swept %d object(s)',
[Report.ResourcePruneReport.RemovedNameCount,
Report.ResourcePruneReport.RemovedObjectCount]));
if Report.PreservedFragmentCount = 0 then
// 没有东西可拆分:每个相交对象都被整体丢弃
LogWholeObjectFallback(SourcePdf);
为什么 PDFiumPas 对未映射字符失败即拒?
因为没有可复现 Unicode 标量的字形无法被诚实重建。重建幸存者意味着用一个字符串调文本设置 API,而这要求每个保留字符都有稳定码点。ToUnicode 数据损坏或缺失的符号化子集字体可能产出空映射,靠猜测重新编码会产出屏幕上看着正确、底下却带着不同字符的输出。PDFiumPas 拒绝:保留字符检查抛异常,异常在 SaveAsRedacted 内部被接住,TPdfRedactionReport.Succeeded 返回 False、消息在 ErrorMessage 中,函数返回 False。同样的规则适用于拆分预算,它抛异常而不是悄悄截断片段集。当文档有你不信任的字体而你想要确定性的旧行为时,设 Options.PreservePartialObjects := False,每个相交对象就整体消失
跨共享作用域的资源剪枝
拆分对象会留下孤儿,而剪枝它们不像 diff 页面级 /Resources 字典那么简单。ISO 32000-1 §7.8.3 允许同一个资源字典同时被多个页面、Form XObject、图案和注释外观流引用。因为一个页面不再使用某个字体名就删掉它,会弄坏仍在使用它的另一个页面。因此 PruneUnusedPdfResources 按作用域工作:它解析 /Contents,无论是直接数组、指向数组的间接引用还是单个流,然后从真正点名资源的操作符收集资源使用——字体用 Tf、XObject 用 Do、图形状态用 gs、颜色空间和图案用 CS、cs、SCN 和 scn、底纹用 sh、标记内容属性用 BDC 和 DP,外加内联图像的 /CS 条目。当一个字典被多个作用域共享时,已用名集合按类别取并集,然后才移除任何东西
只有在指向该字典的每个作用域中都被确认无引用的名字才会被丢。无法有把握解析的作用域原样保留,这是保守方向:未剪枝的文件只是更大,剪错的文件是坏的。幸存字典以携带精确世代号的稀疏增量更新写回,然后一次可达性重写清扫那些名字消失后变得不可达的对象。TPdfResourcePruneReport 报告 ScannedScopeCount、UpdatedScopeCount、RemovedNameCount、RemovedObjectCount、字节数和一个 Succeeded 标志。SaveAsRedacted 在消毒后的输出上自动运行这一步,所以涂黑路径已包含它,但该函数在流级导出,供想单独使用它的管线使用
uses
FPdfCompress;
procedure PruneResourceNames(const SourcePdf, TargetPdf: string);
var
Source, Dest: TFileStream;
Report: TPdfResourcePruneReport;
begin
Source := TFileStream.Create(SourcePdf, fmOpenRead or fmShareDenyWrite);
try
Dest := TFileStream.Create(TargetPdf, fmCreate);
try
// AllowSignedDocument 保持 False:增量重写会
// 让签名覆盖的字节范围失效
PruneUnusedPdfResources(Source, Dest, Report);
if not Report.Succeeded then
raise Exception.Create(Report.ErrorMessage);
Writeln(Format('%d name(s) removed from %d scope(s), %d -> %d bytes',
[Report.RemovedNameCount, Report.UpdatedScopeCount,
Report.SourceByteCount, Report.OutputByteCount]));
finally
Dest.Free;
end;
finally
Source.Free;
end;
end;
把它接进文档管线
涂黑路径从不改动你加载的文档。SaveAsRedacted 捕获一个隔离快照,在那里应用 /Redact 注释,剥离附件,运行消毒趟以移除打开动作、目录动作、名称树、关联文件、AcroForm 和元数据,剪枝资源,然后才写输出流。把那个输出作为独立文档重新打开并重新提取文本,是值得保留在你自己测试套件里的验证步骤,因为它是唯一回答原始问题的检查——读者还能不能拿到那个字符串。要有一个需要规划的后果:拆分替换了页面对象,所以你一直持有的任何 FPDF_PAGEOBJECT 句柄之后都是死的,这与变换后页面对象句柄失效里描述的生命周期陷阱相同
两个相邻环节让工作流完整。决定涂黑矩形放在哪里,通常从提取出的几何开始,结构化文本块与阅读顺序里的块和阅读顺序模型,是比原始字符流更好的候选框来源。把结果呈现给审阅者属于构建安全的 PDF 预览里的加固规则,那里表单填写和 JavaScript 默认关闭。它们合起来覆盖大多数合规工作流需要的循环:定位、操作符级涂黑、重开验证、安全预览。该组件的完整 API 面、试用下载和授权条款在 PDFium Delphi 组件产品页上