技術文章

在 Delphi 中用 PDFiumPas 做運算子層級的 PDF 遮蔽

有人在一個名字上畫了黑框、什麼都不平面化就出貨,審查者選取那個矩形,把名字貼進電子郵件。PDFiumPas 以運算子層級遮蔽回應此事:SaveAsRedacted 只刪除「字元框碰觸到遮蔽矩形」的那些 Unicode 純量,再以原始字型、字級、矩陣、轉譯模式與色彩重建存活者,並對軸對齊的路徑與影像做裁切,而不是整個丟棄

為什麼畫上去的矩形不算遮蔽

加在內容串流之上的繪圖操作什麼也藏不住,因為它底下的文字顯示運算子仍在串流中,也仍對應到字碼點。ISO 32000-1 §9.4 把文字物件定義為 BTET 之間一序列的定位與顯示運算子;事後畫上去的填色矩形只不過是同一串流中的另一個運算子。擷取走的是運算子,不是像素,所以被蓋住的字串會完好地回來。真正的遮蔽必須移除運算元,而不是把輸出弄模糊

顯而易見的安全實作很粗暴:找出邊界框與遮蔽矩形相交的每個頁面物件,整個刪除。早期 PDFiumPas 版本就是這麼做,它正確,但代價高昂。單一個 Tj 可能攜帶整列表格列,所以塗掉一個帳號會連日期、說明與金額一起帶走。一個恰好是全寬表格橫帶的矩形填色,會在整頁上消失。一張發票商標不見了,只因為遮蔽裁到它的一角。3.101.0 版把決策往下移一層,從頁面物件移到運算元

運算子層級遮蔽實際刪除什麼?

PDFiumPas 刪除的是 Unicode 純量,不是文字物件。在 SaveAsRedacted 期間,元件從已載入的文字頁建立「字元對頁面物件」的對應,然後對於受測物件擁有的每個字元,讀取其字元框,並把該框與每個遮蔽矩形求交。碰觸到矩形的字元被標記為移除;其餘標記為存活。若沒有任何相交,物件完全不被碰觸。若每個字元都相交,物件整個移除,與從前完全一樣。只有混合狀況才會觸發分割

Delphi 中 PDFiumPas 運算子層級遮蔽與整物件刪除的比較:舊路徑在一個帳號被蓋住時丟掉整個文字物件,分割路徑則只刪除相交字元,並把每個存活者重新發射為自己的文字物件
只有混合狀況才觸發分割:無相交就不碰物件,全相交就整個移除

每個存活者接著重新發射為自己的文字物件,由原始字型控制代碼、原始字級、逐字元文字矩陣、原始文字轉譯模式,以及父物件的填色與筆觸狀態(含筆觸寬度、線條接合、線條端點與虛線陣列)構成。重用字型控制代碼、而不是解析一個新的,是讓字形在度量上保持一致的關鍵;重用逐字元矩陣,則是不必重跑排版就能讓字距調整與字間空格維持定位的關鍵。代價是物件數量:一個保留字元變成一個文字物件,這正是 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 格式。與路徑相同,旋轉或傾斜的影像,或矩陣帶有退化縮放項的影像,會被整個移除

PDFiumPas 在 Delphi 中如何裁切部分被遮蔽的影像:存活的頁面空間片段經反轉 CTM 對應回正規化影像座標,夾制到單位區間並向內捨入,使沒有任何被遮蔽的像素欄存活
左與上用 Ceil、右與下用 Floor,使切口落在整個像素邊界上
// 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,每個相交物件就會整個消失

跨共用範圍的資源剪除

分割物件會留下孤兒,而剪除它們不像對頁面層級 /Resources 字典做差異比對那麼簡單。ISO 32000-1 §7.8.3 允許同一個資源字典同時被數個頁面、Form XObject、圖樣與註記外觀串流參照。因為某一頁不再使用某個字型名稱就刪掉它,會弄壞另一個仍在使用它的頁面。因此 PruneUnusedPdfResources 逐範圍運作:它解析 /Contents——不論是直接陣列、指向陣列的間接參照,還是單一串流——然後從真正點名資源的運算子收集資源用量:字型用 Tf、XObject 用 Do、圖形狀態用 gs、色彩空間與圖樣用 CScsSCNscn、漸層用 sh、標記內容屬性用 BDCDP,再加上內行影像的 /CS 條目。當一個字典被數個範圍共用時,已用名稱集合會在移除任何東西之前,先依類別取聯集

PDFiumPas 中的資源剪除:三個範圍參照同一個共用資源字典,它們的已用名稱集合依類別取聯集,只有沒有任何範圍參照的名稱會被移除,之後才清掃不可達物件
一個字典可以服務數個頁面、Form XObject 與外觀串流,所以 PDFiumPas 在丟掉任何一個名稱前,先把所有已用名稱集合取聯集

只有在「指向該字典的每個範圍」都確認未參照的名稱才會被丟棄。無法有把握解析的範圍原樣保留,這是保守方向:未剪除的檔案只不過比較大,剪錯的檔案則是壞掉。存活的字典以稀疏增量更新寫回,帶著確切的世代編號;可達性重寫接著清掃「名稱消失後變得不可達」的物件。TPdfResourcePruneReport 回報 ScannedScopeCountUpdatedScopeCountRemovedNameCountRemovedObjectCount、位元組計數與一個 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 元件產品頁