技術文章

PDFlibPas 遮蔽套用:覆蓋物、/RO 與 /IC(Delphi)

沒畫 /IC 色彩的遮蔽標記,走完套用步驟後可能變成一塊透明空隙或一個空心外框,而審閱者對它下的註解還留在檔案裡。PDFlibPas 以 ApplyRedactions 套用 Redact 註解:被覆蓋的內容先移除,覆蓋物取自 /RO,或取自 /IC 並以黑色兜底,標記自己的字典則清空

標記可逆,套用不可逆

ISO 32000-1 §12.5.6.23 把遮蔽拆成內容識別與內容移除。識別階段的 Redact 註解只是個提案,使用者還能看見、移動、重新定義,審閱輪因此才可能。移除則交給應用程式,要求很硬:註解指認的東西全部消失,註解本身消失,任何影像被覆蓋的部分資料必須銷毀——蓋在像素上的剪裁路徑或影像遮罩都不算數。各鍵列在 ISO 32000-1 的 Table 192(ISO 32000-2 為 Table 195),其中多半描述的是移除之後頁面該長什麼樣,而不是該移除什麼

鍵規範中的角色ApplyRedactions 對它做什麼
QuadPoints以 n 個四邊形指定的移除區域;存在時取代 Rect 成為區域分別擦除每個四邊形的邊界框;長度不是 8 的倍數的陣列退回 Rect
RectQuadPoints 缺席時的移除區域使用時先把反轉的角點正規化
IC移除後的 DeviceRGB 填色;缺席代表透明;RO 存在時被忽略存在時保留,缺席時補上黑色
ROForm XObject 覆蓋物;優先於 IC、OverlayText、DA 與 Q安裝為正常外觀並展平到頁面上
OverlayText、Repeat畫在區域上的標籤,可平鋪畫進產生的覆蓋物,Repeat 為 true 時平鋪
DA、Q標籤的字型、大小與色彩;對齊方式大小與色彩讀自 DA,對齊讀自 Q

以 AddRedactAnnotation 建立標記

TPDFlib.AddRedactAnnotation(Left, Top, Width, Height, FillR, FillG, FillB, OverlayText, RepeatOverlay) 在所選頁面加一個標記,回傳頁面新的註解數——這也是該標記 1 起始的索引;什麼都沒加時回傳 0。座標遵循目前的原點與單位,所以範例切到左上原點,那裡的 Top 是上緣。這個呼叫寫出 /Rect、由填色分量構成的 /IC,以及固定的 /DA:/F0 10 Tf 1 1 1 rg,10 點白字;非空的 OverlayText 會加上 /OverlayText、/Repeat 與 /Q 1,標籤置中。Redact 註解是 PDF 1.7 的功能,較舊的標頭會升到 1.7。此時還不寫任何外觀串流;待套用的標記長什麼樣,取決於審閱者應用程式怎麼畫它。標籤固定是白的,所以 OverlayText 要配深色填色——淺黃底上標籤會看不見。每個 PDFlibPas 版本都能建立標記

uses
  System.SysUtils, PDFlibrary;

procedure MarkForReview(const SourceFile, ReviewFile: string);
var
  Pdf: TPDFlib;
  Index: Integer;
begin
  Pdf := TPDFlib.Create;
  try
    if Pdf.LoadFromFile(SourceFile, '') <> 1 then
      raise Exception.CreateFmt('Cannot open %s', [SourceFile]);
    Pdf.SetOrigin(1);            // 左上原點:Top 是上緣
    Pdf.SetMeasurementUnits(0);  // 點
    if Pdf.SelectPage(1) <> 1 then
      raise Exception.Create('Page 1 not found');
    // 證人姓名:黑色填色,置中白標籤
    Index := Pdf.AddRedactAnnotation(72, 180, 220, 14, 0, 0, 0, 'REDACTED');
    if Index = 0 then
      raise Exception.Create('Mark was not added');
    // 帳戶區塊:把豁免代碼平鋪在更高的框裡
    Pdf.AddRedactAnnotation(72, 410, 300, 40, 0, 0, 0, '(b)(6)', 1);
    // 只是標記;原始內容的每個位元組都還在
    Pdf.SaveToFile(ReviewFile);
  finally
    Pdf.Free;
  end;
end;

QuadPoints 決定移除區域;回覆留住自己的文字

標記一旦帶上 /QuadPoints,這些四邊形就決定 ApplyRedactions 移除什麼,Rect 只為顯示框住它們。用文字選取工具做的標記,通常每個選取列一個四邊形,所以用 SetAnnotRect 調整這種標記的大小,只在畫面上挪動它的框,移除區域留在原地;真正搬動區域的呼叫是 SetAnnotQuadPoints,而只靠 Rect 的標記,GetAnnotQuadCount 會回傳 0。掛在標記上的文字走另一套規則。審閱工具把備註放在 /Contents、作者放在 /T,而遮蔽標記上的備註往往正在描述被移除的那件事(「client SSN, confirm with legal」)。ApplyRedactions 會清空標記自己的字典,但透過 /IRT 串在它底下的回覆是獨立註解,只有矩形與遮蔽區域重疊時才會刪除,所以停在邊界外的回覆留得住文字。下面的清單程式碼印出每個標記的四邊形數與串在其底下的回覆

Delphi 的 PDFlibPas 遮蔽流程裡 QuadPoints 與 Rect 的剖析:每個四邊形框住自己的移除區域,邊界外的回覆留住文字
quadpoints 一次框住一列的移除範圍,所以搬標記靠的是 SetAnnotQuadPoints,區域外的邊界回覆留得住文字
procedure ListPendingMarks(Pdf: TPDFlib);
var
  P, I, Parent: Integer;
begin
  for P := 1 to Pdf.PageCount do
  begin
    if Pdf.SelectPage(P) <> 1 then
      Continue;
    for I := 1 to Pdf.AnnotationCount do
      if Pdf.GetAnnotType(I) = 'Redact' then
        Writeln(Format('p%d #%d by %s, quads=%d: %s',
          [P, I, Pdf.GetAnnotTitle(I), Pdf.GetAnnotQuadCount(I),
           Pdf.GetAnnotContentsEx(I)]))
      else
      begin
        Parent := Pdf.GetAnnotReplyTo(I);   // /IRT 缺席時為 0
        if (Parent > 0) and (Pdf.GetAnnotType(Parent) = 'Redact') then
          Writeln(Format('  reply #%d to #%d: %s',
            [I, Parent, Pdf.GetAnnotContentsEx(I)]));
      end;
  end;
end;

ApplyRedactions 之後出現的是哪個覆蓋物?

只要存在,/RO 條目就贏,Table 192 的優先序規則是這麼要求的。ApplyRedactions 把那個 Form XObject 安裝為註解的正常外觀(/AP /N),丟掉任何外觀狀態並將它展平,所以 /IC、/OverlayText、/DA 與 /Q 對這個標記都沒作用。放置是唯一微妙的部分。規範說覆蓋物的原點對齊 Rect 的左下角,而展平是把 Form 的 BBox 經過它的 Matrix 放置到 Rect 上,正是展平套用到表單欄位 widget 的 BBox 與 Matrix 放置。BBox 為 [0 0 w h]、尺寸恰為該矩形、帶單位 Matrix 的覆蓋物,兩種讀法下看起來一樣,所以自己的工具產生 /RO 標記時就照這個形狀開

沒有 /RO 時,ApplyRedactions 會先把字典正規化,再自己建外觀。它刪掉 /C 與 /BS,把 /Border 設成 [0 0 0]:這些條目描述的是待套用的標記,通常是一圈彩色外框,把它燒進最終頁面,完成的遮蔽會看起來像沒做完。邊框沒了,填色蓋滿整個矩形。標籤畫在產生的 Form 裡,大小與色彩取自 /DA,對齊取自 /Q,平鋪取自 /Repeat,字型名稱 F0 則在該 Form 自己的資源裡綁到標準 Helvetica。跨工具傳檔時這個綁定就重要了:ISO 32000-1 §12.7.3.3 把 DA 字型名稱定義為互動表單預設資源(/DR)裡的條目,沒有表單欄位的文件沒有它,另一個套用同一標記的應用程式可能代換成備援字型。PDFlibPas 跳過那次查找,用 Helvetica 的標籤適合拉丁文字,例如 REDACTED 或豁免代碼

Delphi 的 PDFlibPas 在 ApplyRedactions 之後的覆蓋物決策流程:RO 存在時勝出並展平,IC 缺席時退回黑色
RO 這個 form XObject 會安裝為正常外觀並展平,沒有的標記則重建外觀,IC 缺席時以黑色填滿

缺席的 /IC 變成一塊黑

規範對缺席 /IC 的預設是內部透明,PDFlibPas 是刻意偏離它的。移除之後,內部透明露出的是紙——名字原本在的地方出現一塊白縫,而那塊白縫與本來就沒東西的版面看起來一模一樣。讀者分不清遮蔽與空白,審閱者不比對擷取文字就無法確認遮蔽發生過。空心框——套用步驟把只有外框的標記的待套用外觀展平時會產出的那種——則以另一種方式失敗:它讀起來像一份沒人處理過的提案。我們認真考慮過照字面遵循預設,最後選了黑塊,因為「這裡有東西被隱去」的可見跡象,本身就是遮蔽文件要告訴讀者的一部分,而且這個選擇只影響作者從未聲明填色的標記。字典裡已經有的 /IC 不論值為何都照用,所以要求白色的產生端拿到的就是白色

覆蓋物畫上去之前,什麼已經沒了

ApplyRedactions 逐頁工作,先完成所有移除才展平任何覆蓋物,所以覆蓋物永遠不會被自己的區域掃到。對每一頁,它為每個四邊形收一個矩形,沒有四邊形的標記則取其 Rect,然後對每個矩形擦除每一層內容:

  • 文字逐字形切除,量測依據頁面字型與完整文字狀態,部分被覆蓋的字串其剩餘部分留在原位
  • 畫進區域內的向量路徑、行內影像與漸層,從內容串流中刪除
  • Form XObject 遞迴進入;別的頁面或放置位置也在畫的 Form,透過私有副本遮蔽,那些頁面保有原始版本
  • 影像 XObject 只失去被覆蓋的像素,以黑色覆寫(CMYK 為全 K 墨量);JPEG、JPEG 2000、JBIG2 與 CCITT 影像解碼後以無損 Flate 串流存回
  • 在加標籤的 PDF 裡,掛在被移除標記內容上的 /Alt 與 /ActualText,在頁面內容與結構樹中都會清除
  • 矩形碰到區域的其他註解會被刪除,widget 停在那裡的表單欄位則失去其值

像素這一步,就是規範影像條款的落地。在掃描護照上蓋黑矩形,眼睛滿意了,§12.5.6.23 照樣不過,因為有人一抽出影像,原始樣本就解碼還原。同一具引擎也在RedactRegion——直接從你傳入的矩形移除內容、並與 SanitizeDocument 配對的背後,全程不讀任何註解。內容消失後,每個標記被展平,字典清到只剩 /Type 與 /Subtype。展平把註解從頁面的 /Annots 陣列移除,但字典仍是文件裡的一個物件,會把它寫出去的存檔,等於把 /Contents、/RC、/T 和整條審閱軌跡交到收件人手上

存檔不帶未遮蔽的修訂版

增量存檔會讓前面的一切白做。在 §7.5.6(ISO 32000-1 與 ISO 32000-2 同號)之下,增量更新把改動的物件附加到檔案結尾,原始位元組原封不動,所以帶著頁面內容、影像與已標記註解的原始修訂版,任何人只要在較早的 %%EOF 處切開檔案就讀得到;PDFlibPas Small Business 版的 ExtractDocumentRevision 一個呼叫就能做到。ApplyRedactions 之後,用 SaveToFile 寫一份新檔,別用會附加的 SaveChanges。存檔之前,GarbageCollectObjects 掃過物件表,丟掉再也沒人參照的物件,例如標記在套用步驟之前帶著的外觀串流;完整存檔預設跳過這趟清掃,除非 SetGarbageCollect 打開它,或你傳給 SaveToFileOptions 的 TPDFlibSaveOptions 記錄設了 GarbageCollect 欄位。中繼資料是最後的缺口。SanitizeDocument(SANITIZE_METADATA or SANITIZE_XMP_PACKET) 把 Author、Subject、Keywords、Creator 與 Producer 清空並移除 XMP 串流,Title 留給你自己檢查。這裡別用 SANITIZE_ALL:它包含 SANITIZE_ANNOTATIONS,會把文件裡剩下的每一個註解都刪掉,你想保留的備註也在其中

Delphi 的 PDFlibPas 在 ApplyRedactions 之後的存檔時間線:增量附加讓原始版本仍可復原,所以發布流程是 sanitize、垃圾回收再加完整重寫
套用之後,發布路徑是清理中繼資料、掃掉無人參照的物件、存成新檔,因為增量存檔會讓未遮蔽的修訂版保持可讀

ApplyRedactions 與 RedactRegion 一樣,Enterprise 版才提供。在普通 TPDFlib 上它回傳 0,並把 LastErrorCode 設為 PDFLIB_ERROR_ENTERPRISE_REQUIRED(900),所以發布這一步用的是 PDFlibEnterprise 單元裡的 TPDFlibEnterprise

uses
  System.SysUtils, PDFlibrary, PDFlibEnterprise;

procedure ApplyAndRelease(const ReviewFile, ReleaseFile: string);
var
  Pdf: TPDFlibEnterprise;
  Applied, Orphans: Integer;
begin
  Pdf := TPDFlibEnterprise.Create;
  try
    if Pdf.LoadFromFile(ReviewFile, '') <> 1 then
      raise Exception.CreateFmt('Cannot open %s', [ReviewFile]);
    Applied := Pdf.ApplyRedactions;   // 每一頁的每個 Redact 標記
    if Applied = 0 then
      raise Exception.CreateFmt('No marks applied (error %d)',
        [Pdf.LastErrorCode]);
    // Info 與 XMP 還說得出頁面已不再顯示的內容
    Pdf.SanitizeDocument(SANITIZE_METADATA or SANITIZE_XMP_PACKET);
    Orphans := Pdf.GarbageCollectObjects;
    // 完整重寫到新檔;遮蔽之後絕不 SaveChanges
    if Pdf.SaveToFile(ReleaseFile) <> 1 then
      raise Exception.Create('Save failed');
    Writeln(Format('%d marks applied, %d unreferenced objects dropped',
      [Applied, Orphans]));
  finally
    Pdf.Free;
  end;
end;

Expected:ApplyRedactions 回傳它在所有頁面展平的 Redact 標記數——MarkForReview 寫出的審閱副本是 2——GarbageCollectObjects 回傳清掃丟掉了多少無人參照的物件

不論標記是哪個工具畫的,它的 /RO 與 /IC 條目決定收件人在頁面上看到什麼,而 PDFlibPas Enterprise for Delphi 的套用步驟決定底下少了什麼