技术文章

在 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 來精確放置任何东西;使用 GetLoadedPageBox 呼叫 pbMediaBox 就能給你这个资訊

这段请读两次:一个填满的矩形是在视觉上覆蓋内容,它并没有将内容移除。在矩形底下的文字、图片或矢量图形,依然存在於 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 偏移量、固定缩放比例为 StitchLoadedPage 來呼叫 0.5,将來源页面放在右半边。这个写死的 0.5 假设了來源与目标共用相同的宽度;如果不是,來源页面将无法完美地填满它的半边,此时你会需要使用一般的 StitchLoadedPage,并带入你自己从两个媒体框計算出來的缩放比例

簡化的 XObject 策略及其 ISO 权衡

在你在各個查看器間信任输出之前,这里有一个实作上的刻意捷径是你必须知道的。一个正確的 N-up 拼版会将來源页面的内容包裝在 Form XObject 中——这是一个独立的可繪製对象,ISO 32000-1 §8.10.1 指出它必须带有 /Type /XObject/Subtype /Form,以及它自己的 /BBox 剪裁框。HotPDF 的第九輪拼版并没有建立这个包裝器。取而代之的是,它将來源页面字典本身直接以 /Resources /XObject 这个名稱註冊在目标的 StitchSrc 之下,然后用 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 一起发布