Technical Article

使用 PDFium 的 Form XObject 实现可重用页面印章

将水印或徽标盖到文档的每一页上看起来是个五分钟的工作,直到您在文件大小检查器中打开结果。显而易见的方法是遍历页面,在每一页上再次构建相同的文本或图像对象。这在视觉上有效,但在某种程度上它是呈复利式浪费的。直接绘制到百页报告上的对角线“DRAFT”水印是坐在内容流中的相同路径和文本数据的 100 个副本,并且保存的文件携带了它们中的每一个。

Form XObject 是 PDF 为了确切避免这种情况而提供的构造。它将一块可重用的内容(整页或小模板)封装到单个命名的对象中,该对象可以在许多位置绘制多次。内容在文件中仅存在一次。每个需要印章的页面都包含一条简短指令,说明“使用此变换在此处绘制 XObject N”。百页水印随后向文件添加一个内容对象而不是 100 个,这就是随着页面计数线性增长的文档与不增长的文档之间的区别。水印、徽标印章、页码模板和印章都是同一种形式的问题,而 Form XObject 是处理其中每一个的正确工具。

为什么一个存储的对象优于 100 次重画

这种节省是结构性的,而不是装饰性的。PDF 页面通过执行其内容流(一组绘制运算符序列)来渲染。当您每页重新绘制印章时,您将该印章的完整运算符序列追加到每个页面的流中,并且字节重复的次数与您的页面数相同。Form XObject 将这些运算符移入在文档中存储一次的单个流中。单个页面保留的引用很小:它推送变换矩阵、调用 XObject 并恢复状态。页面计数不再使艺术作品的开销倍增。

当印章很重时,这最重要。带有数百个路径段的矢量图章或徽标位图存储成本很高。存储一次并引用,重型部分只支付一次,每页的开销是几个字节的调用。页面上的视觉结果与直接重画完全相同,这就是重点。读者分不出区别;文件大小则完全可以。

将页面捕获到 XObject 中

PDFium 从现有页面构建可重用对象。源是您已打开的某个文档中的页面、除水印艺术作品外什么都不包含的微型单页 PDF,或者更大型文件的特定页面。CreateXObjectFromPage 将该源页面的内容捕获到属于目标文档(即您正在盖章的文档)的可重用句柄中。

var
  Dest, Stamp: TPdf;
  XObject: TPdfXObject;
begin
  Dest := TPdf.Create;
  Stamp := TPdf.Create;
  try
    Dest.LoadFromFile('Report.pdf');
    Stamp.LoadFromFile('Watermark.pdf');   // one page of artwork

    // Capture page 0 of the stamp document into a reusable handle that
    // is owned by Dest. Source must be active; the index is zero-based.
    XObject := Dest.CreateXObjectFromPage(Stamp, 0);
    if XObject = nil then
      raise Exception.Create('Could not build the stamp XObject');
    // ... place it, then free it before closing Stamp (see below) ...

签名是 CreateXObjectFromPage(Source: TPdf; SourcePageIndex: Integer): TPdfXObject。该方法在失败时返回 nil 而不是引发异常,因此上面的显式检查不是可选的。返回的句柄是您拥有的 TPdfXObject,附加在其上的两个生命周期限制是这整个过程中容易让人跌倒的部分,因此它们在下面有其自身的部分。

在页面上放置印章

捕获的 XObject 本身不起任何作用。为了让它出现,您使用 InsertFormObjectFromXObject 将其副本插入到文档的当前页面上。该调用返回底层的页面对象(即 FPDF_PAGEOBJECT),返回的句柄是您定位放置的方式。没有变换,印章会落在源页面自身坐标的原点,这很少是您想要的位置。

因为 InsertFormObjectFromXObject 每次调用插入一个副本并每次都传回新鲜的页面对象,所以您可以在一个页面上以不同的变换绘制同一个 XObject 几次,且存储的内容在文件中仍然只计算一次。角落徽标和微弱的全页水印可以来自相同的捕获对象。

var
  PageObj: FPDF_PAGEOBJECT;
  M: TPdfMatrix;
begin
  // The current page of Dest receives one copy of the XObject.
  PageObj := Dest.InsertFormObjectFromXObject(XObject);
  if PageObj = nil then
    raise Exception.Create('Insert failed on this page');

  // Position it: move 200 units right, 500 up, at 70% scale.
  M := TPdfMatrix.Create;
  try
    M.Scale(0.7, 0.7);
    M.Translate(200, 500);
    FPDFPageObj_SetMatrix(PageObj, M.Handle);
  finally
    M.Free;
  end;
  // Dest.SaveLoadedDocument(...) when every page is done.
end;

让人跌倒的句柄生命周期规则

两个限制管理着 XObject 句柄,忽略其中任何一个都会产生与其原因看起来无关的失败。首先,在您调用 CreateXObjectFromPage 的瞬间,源文档必须是活动的。捕获从活动的源文档中读取源页面的内容,因此在构建句柄时,该文档及其页面必须打开且有效。其次(这也是让人感到意外的),在关闭源页面之前必须释放句柄,在实践中,是在您关闭或释放它来自的源文档之前。

原因在于 XObject 是对源文档仍拥有的结构的引用。它不是在源文档消失后您可以携带的独立的、自包含的副本。首先关闭源文档,句柄就会指向已被拆除的内容,因此稍后释放它或对它的任何其他使用都会在不再有效的内存上操作。症状是悬空句柄的经典症状:关闭时发生访问冲突,或者取决于分配顺序而移动的间歇性损坏,其堆栈指向清理代码而不是实际导致问题的行。解决办法是排序,而不是防御性编码。构建 XObject、将其插入到需要它的每个页面、释放 XObject,然后才关闭源文档。TPdfXObject 析构函数为您释放底层的 PDFium 句柄,因此在正确的时间释放包装器是您的全部责任。

矩阵,以及它的六个数字意味着什么

放置是二维仿射变换,即 PDF 在每个地方定位内容所使用的相同变换(ISO 32000-1,第 8.3.4 节)。它是六个数字,写作 a, b, c, d, e, f,PDFium 将它们公开为 FS_MATRIX 记录。它们将点从对象自身的空间映射到页面空间:

// x' = a*x + c*y + e
// y' = b*x + d*y + f
//
// a, d : horizontal and vertical scale
// b, c : the shear / rotation terms
// e, f : translation (where the origin lands on the page)

您可以手动填入这六个值,但手动组合它们是旋转容易出错的地方,因为旋转将 a, b, c, d 四个值混合在了一起。TPdfMatrix 包装器为您组合了常见操作并随行后乘,因此 TranslateScaleRotate 按您调用的顺序进行链式操作。对角线水印是旋转后跟平移以使其重新居中;角落徽标是缩放后跟平移。当矩阵准备好时,将其原始值交给 FPDFPageObj_SetMatrix(PageObj, M.Handle)(其中 M.Handle 是底层的 FS_MATRIX)。当您宁愿传递数字而不构建包装器时,可以使用直接接受六个双精度值作为参数的更底层 FPDFPageObj_Transform 函数。

按正确的顺序盖章每一页

完整模式将这些部分与生命周期规则要求的排序结合在一起。打开这两个文档、捕获一次印章、遍历目标页面依次选择每一页并插入以及定位副本、然后释放 XObject、然后保存,并让源文档最后关闭。

procedure StampEveryPage(const ASource, AStamp, AOutput: string);
var
  Dest, Stamp: TPdf;
  XObject: TPdfXObject;
  PageObj: FPDF_PAGEOBJECT;
  M: TPdfMatrix;
  i: Integer;
begin
  Dest := TPdf.Create;
  Stamp := TPdf.Create;
  try
    Dest.LoadFromFile(ASource);
    Stamp.LoadFromFile(AStamp);

    // 1. Capture the artwork once. Stamp is active here.
    XObject := Dest.CreateXObjectFromPage(Stamp, 0);
    if XObject = nil then
      raise Exception.Create('Could not capture the stamp page');
    try
      // 2. Place a copy on every page of Dest.
      for i := 0 to Dest.PageCount - 1 do
      begin
        Dest.CurrentPageIndex := i;          // make page i current
        PageObj := Dest.InsertFormObjectFromXObject(XObject);
        if PageObj = nil then
          Continue;

        M := TPdfMatrix.Create;
        try
          M.Rotate(45);                      // diagonal watermark
          M.Translate(150, 100);             // nudge into position
          FPDFPageObj_SetMatrix(PageObj, M.Handle);
        finally
          M.Free;
        end;
      end;
    finally
      XObject.Free;                          // 3. free BEFORE Stamp closes
    end;

    // 4. Write the result while Dest is still open.
    Dest.SaveLoadedDocument(AOutput);
  finally
    Stamp.Free;                              // source closes last
    Dest.Free;
  end;
end;

try 块的形状起着真实的作用。内部 finally 在控制能够到达释放 Stamp 的外部 finally 之前释放 XObject,因此句柄始终在源仍处于活动状态时被释放(即使在中途引发异常)。正确处理该嵌套,生命周期规则就会自行照料。(使用您的构建公开的任何当前页面选择器;循环体在任何一种情况下都是相同的。)

盖章是用于构建和编辑页面内容的更大工具包的一个角落。如果您的印章本身是图像而不是捕获的页面,在 Delphi 中使用 PDFium 将图像转换为 PDF 文档涵盖了首先将该位图载入到文档中。当您想要与可见印章一起携带的是文件而不是页面上的墨水时,在 Delphi 中使用 PDF 附件展示了嵌入式文件的一侧。所有这些都随适用于 Delphi 和 C++Builder 的 PDFium 组件一起交付,与本博客其他地方介绍的渲染、编辑和文档 API 一起提供。