Technical Article

在 Delphi 中使用 HotPDF 進行 PDF 遮蔽與 N-up 拼版

一個需求落到了您的辦公桌上:拿一批已經渲染好的對帳單,將帳號塗黑,然後為了節省紙張,每張紙印兩頁。這個任務的兩個部分,都是對一份不是您所建立的 PDF 進行內容串流 (content-stream) 的外科手術,因此沒有友善的頁面畫布可供繪製,也沒有字型管理員可以依賴。您是直接在編輯已載入文件的物件圖 (object graph),將原始的繪圖運算子附加到由其他工具所佈局的頁面上。HotPDF 針對此需求恰好暴露了兩個進入點,而其中比較危險的那一個,看起來卻似乎毫無害處。

HotPDF 是一個適用於 Delphi 與 C++Builder 的原生 VCL PDF 元件。其第九輪的已載入文件 API 加入了第一批方法,用以在您從磁碟開啟(而非從頭建立)的頁面上建立全新的內容。其中有兩個是這裡的主題:RedactLoadedRect 會在一個區域上繪製不透明的矩形,而 StitchLoadedPage 則是將一個頁面縮放並繪製到另一個頁面上。兩者的運作方式,都是將 ISO 32000-1 §8.5 的內容串流運算子寫入該頁面的 /Contents 串流中。了解這些運算子的作用——更重要的是,了解它們沒有做什麼——這就是一個可用工具與資料外洩之間的差異。

將運算子附加至已載入的頁面

當您使用一般的 HotPDF API 建立頁面時,該元件擁有內容串流,並會為您序列化您的 TextOut 與向量呼叫。但已載入的頁面則不同:其 /Contents 是一個現有的串流物件,可能是共用的,也可能是內容陣列的一部分,而您必須在不破壞原有內容的情況下,將內容接入其中。第九輪引進了三個能讓這項工作變得安全的小型輔助函式。NewIndirectStream 配置了一個全新的間接 THPDFStreamObject,帶有空的緩衝區以及 /Length 0 的條目;ResolveLoadedStream 沿著間接參考一路追蹤到底層的串流;而 AppendLoadedStream 則在串流的結尾寫入原始位元組,並覆寫 /Length,讓儲存後的物件保持格式正確。

這兩個公開方法所遵循的模式都是相同的。找到頁面的 /Contents,將其解析為串流,如果沒有可用的串流,則建立一個並附加它。然後附加運算子。因為新的位元組是加在串流的結尾,畫家模型 (painter's model) 保證了它們會渲染在原始版面繪製的所有內容之上。這個順序是遮蔽矩形背後的全部機制,這也是為什麼該矩形並非多數人所假設的那樣的原因。

RedactLoadedRect:是不透明覆蓋,而不是刪除

RedactLoadedRect 接收一個以零起始的頁面索引、四個使用者空間座標,以及 0 到 1 範圍內的三個色彩元件:

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('statement.pdf') > 0 then
    begin
      // Cover the account-number band on page 1 with solid black.
      // Coordinates are PDF user space: origin bottom-left, points.
      Pdf.RedactLoadedRect(0, 56, 690, 320, 706, 0, 0, 0);
      Pdf.SaveLoadedDocument('statement-covered.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

在底層,該方法會發射三個運算子到內容串流中:以 DeviceRGB 設定填滿顏色 (r g b rg)、一個矩形路徑 (x y w h re),以及一個填滿動作 (f)。寬度與高度是透過 X2 - X1Y2 - Y1 推導出來的,因此您只需傳入兩個對角,讓該方法計算範圍。傳入 0, 0, 0 作為顏色會得到一個黑條;傳入 1, 1, 1 則會得到一個與白色頁面相配的白條。座標是該載入頁面本身的使用者空間,這表示原點在左下角,單位是點 (points),這也表示您需要頁面的 /MediaBox 來精確放置任何東西;使用 pbMediaBox 呼叫 GetLoadedPageBox 就能給您這個資訊。

這段請讀兩次:一個填滿的矩形是在視覺上覆蓋內容,它並沒有將內容移除。在矩形底下的文字、圖片或向量圖形,依然存在於 PDF 中,依然在物件圖內,依然可以被任何複製頁面、執行文字提取器,或單純只是將您的矩形從內容串流中刪除的人給提取出來。這是視覺上的遮罩,而不是法律或資安意義上的遮蔽。如果您要隱藏的是真正敏感的資料——帳號、醫療紀錄、身分資訊,任何受管制的東西——用黑盒子覆蓋它然後把檔案發送出去,就是一個等著被發現的資料外洩事件。真正的遮蔽需要刪除底層的內容物件,而不是在上面塗黑。

該方法的名稱寫著「Redact (遮蔽)」,這是一個針對結果可能會被如何誤讀的實用警告,而不是對它會刪除什麼所做的承諾。該實作在自己的註解中對此非常誠實:它稱自己為「視覺遮蔽原語 (visual redaction primitive)」,並指出會移除內容的遮蔽需要一個能夠走訪並重寫現有運算子的內容串流直譯器。HotPDF 的已載入文件路徑在此並沒有這麼做。因此安全守則很狹窄:將 RedactLoadedRect 用於非敏感的裝飾性遮罩——隱藏草稿浮水印、在截圖前清空某個區域、覆蓋內部校樣上過時的標誌。只要漏出盒子底下的東西會造成影響的那一刻起,這個方法就是錯誤的工具,正確的答案是在沒有該資料的情況下重新產生文件,或是使用真正的內容移除管線。

StitchLoadedPage:縮放、平移、繪製

N-up 拼版 (imposition) 是一個比較友善的問題,因為沒有任何東西被隱藏,只有被重新排列。StitchLoadedPage 接收一個目標頁面索引、一個來源頁面索引、一個 X/Y 偏移量,以及一個縮放比例,然後它會將來源頁面以該位置與大小繪製到目標頁面上:

// Overlay page 2 (index 1) onto page 1 (index 0),
// scaled to 70% and nudged up-right.
Pdf.StitchLoadedPage(0, 1, 40, 380, 0.7);

// Convenience 2-up: source page on the right half of the target.
Pdf.StitchLoadedPageSideBySide(0, 1);

它所附加的運算子字串是一個標準的轉換與繪製序列:q 用以儲存圖形狀態,一個在對角線上帶有縮放比例、平移插槽中帶有偏移量的 cm 矩陣,/StitchSrc Do 用以呼叫外部物件,然後 Q 用以還原狀態。這對 q/Q 很重要:它隔離了轉換,這樣被拼貼的頁面就不會將其座標系統滲透到之後附加的任何東西上。該方法也防範了明顯的錯誤——索引超出範圍、目標等於來源、非正數的縮放比例(它會被箝制為 1.0)——並且會安靜地退出而不是引發例外,因此請檢查您的輸入,因為靜默的無操作 (no-op) 看起來和成功一模一樣。

StitchLoadedPageSideBySide 是建構在一般方法之上、單薄的便利函式。它讀取目標頁面媒體框的寬度,將其減半,然後以該半寬作為 X 偏移量、固定縮放比例為 0.5 來呼叫 StitchLoadedPage,將來源頁面放在右半邊。這個寫死的 0.5 假設了來源與目標共用相同的寬度;如果不是,來源頁面將無法完美地填滿它的半邊,此時您會需要使用一般的 StitchLoadedPage,並帶入您自己從兩個媒體框計算出來的縮放比例。

簡化的 XObject 策略及其 ISO 權衡

在您在各個檢視器間信任輸出之前,這裡有一個實作上的刻意捷徑是您必須知道的。一個正確的 N-up 拼版會將來源頁面的內容包裝在 Form XObject 中——這是一個獨立的可繪製物件,ISO 32000-1 §8.10.1 指出它必須帶有 /Type /XObject/Subtype /Form,以及它自己的 /BBox 剪裁框。HotPDF 的第九輪拼版並沒有建立這個包裝器。取而代之的是,它將來源頁面字典本身直接以 StitchSrc 這個名稱註冊在目標的 /Resources /XObject 之下,然後用 Do 來繪製它。一個頁面字典與一個 Form XObject 共享了夠多的內容模型——兩者都參考了一個內容串流與一個資源字典——因此許多閱讀器都會渲染這個結果。

但它不是一個符合規範的 Form XObject。它缺乏 /Subtype /Form 標記以及它自己的 /BBox,這意味著嚴格的使用端有權忽略這個 Do,或是以不同於您預期的方式來剪裁它。此輪的技術說明(TechnicalNotes)直言不諱:這種方法「在多數閱讀器下都能渲染」,但「不是嚴格符合 ISO 規範的 Form XObject」,而要完全符合規範,則需要將合成出一個真正的 Form XObject 串流作為獨立的步驟。因此,請以對待任何不合規結構的方式來對待拼版的輸出:在您的客戶實際執行的特定檢視器中驗證它,而不僅僅是在您機器上的那一個,如果您需要存檔用的或保證能過嚴格驗證器的 PDF,請不要依賴這條路徑。同樣的紀律適用於任何您建立在已載入物件圖上的東西,這也就是為什麼每當您以程式化方式改變文件時,在 Delphi 中進行 PDF 預檢就能在發布管線中贏得它的一席之地。

它們適合與不適合的地方

這兩個方法都是內容串流工具,因此其心智模型與您進行直接繪圖時所使用的模型相同。如果您曾經使用該元件從頭建立過頁面,那麼這些呼叫背後的向量與色彩運算子,您在在 Delphi 中進行 HotPDF 畫布繪圖中應該會覺得很眼熟;唯一的差別在於,這裡您是附加在其他人所編寫的串流上,而不是您自己擁有的串流上。請記住三個界線:

  • 遮蔽是表面功夫。RedactLoadedRect 會在內容上塗色,永遠不會刪除它。對於任何敏感資訊,請重新產生來源或使用真正的內容移除——黑盒子不是安全性。
  • 拼版在設計上是不合規的。來源頁面被參考為一個缺少 §8.10.1 /Subtype /Form/BBox 的偽 XObject,因此請在您的目標檢視器中確認渲染結果,並在需要嚴格驗證的地方避免使用它。
  • 座標是頁面使用者空間。左下角為原點,單位為點,由頁面本身的媒體框所驅動。在放置任何東西之前,請先使用 GetLoadedPageBox 讀取框線,因為您載入的頁面大小,可能並不是您所假設的那樣。

在這些限制內使用,這對工具涵蓋了一個真實的工作流程:重新排列頁面以進行列印、遮罩非機密區域,然後使用 SaveLoadedDocument 將結果寫回——這一切都不需要進行完整的重新渲染。包含這些拼版與遮罩原語的已載入文件 API,與來自同一輪的表單欄位、註解與 FDF 方法,一同隨 Delphi 與 C++Builder 版的 HotPDF Component 一起發布。