技术文章

用 PDFium 的 Form XObject 做可复用页面图章

把水印或 logo 盖到文档的每一页上,看起来像是五分钟的活,直到你用文件大小检视工具打开结果。显而易见的做法是遍历页面,在每一页上把同样的文本或图像对象再造一遍。这在视觉上管用,而它的浪费是会累加的。一个斜向的 "DRAFT" 水印直接画到一份一百页的报告上,就是一百份同样的路径和文本数据坐在各自的内容流里,而保存下来的文件把它们每一份都带着

Form XObject 正是 PDF 为避免这件事而提供的构造。它把一段可复用的内容——一整页,或者一小块模板——包成一个具名对象,可以在许多位置被绘制许多次。内容在文件里只存在一次。每一个想要这个图章的页面持有的是一句简短的指令,意思是"在这里、按这个变换画 XObject N"。于是一份一百页的水印给文件添的是一个内容对象而不是一百个,而这就是一份随页数线性膨胀的文档和一份不这样的文档之间的差别。水印、logo 图章、页码模板和印章都是同一形状的问题,而 Form XObject 对它们中的每一个都是对的工具

示意图:把水印操作符画到每一页 PDF 上,与用 PDFium 把它们一次性存进一个 Form XObject 的对比
每页重画图章会把它的字节复制进每一个内容流,而 Form XObject 把这份图形存一次,让每一页去引用它

为什么存一次胜过重画一百次

这份节省是结构性的,不是装点门面。一个 PDF 页面是靠执行它的内容流来渲染的,那是一串绘图操作符。当你按页重画图章时,你是在把那个图章完整的操作符序列追加到每一页的流里,字节被复制的次数就是你有多少页。Form XObject 把那些操作符搬进一个在文档里只存一次的流。单个页面留着的引用很小:它压入一个变换矩阵、调用这个 XObject、再恢复状态。页数不再乘上这份图形的代价

图章越重,这一点越要紧。一个有几百段路径的矢量印章,或者一张 logo 位图,存起来都很贵。存一次、被引用,重的那部分只付一次钱,而每页的额外开销只是几个字节的调用。页面上的视觉结果与直接重画完全一致,这正是要点。读者分辨不出差别;文件大小可分辨得很清楚

把一个页面捕获成 XObject

PDFium 从一个已有的页面构建这个可复用对象。来源可以是你已打开的某份文档里的一页、一份只装着你的水印图形的单页 PDF,或者一份更大文件中的某一页。CreateXObjectFromPage 把那个源页面的内容捕获成一个可复用的句柄,这个句柄归目标文档所有,也就是你正在盖章的那一份

var
  Dest, Stamp: TPdf;
  XObject: TPdfXObject;
begin
  Dest := TPdf.Create(nil);
  Stamp := TPdf.Create(nil);
  try
    Dest.FileName := 'Report.pdf';
    Dest.Active := True;
    Stamp.FileName := 'Watermark.pdf';   // 一页图形
    Stamp.Active := True;
    if not (Dest.Active and Stamp.Active) then
      raise Exception.Create('Could not open the input documents');

    // 把图章文档的第 0 页捕获成一个归 Dest 所有的可复用句柄。
    // 源必须处于 Active 状态;这个索引从零开始
    XObject := Dest.CreateXObjectFromPage(Stamp, 0);
    if XObject = nil then
      raise Exception.Create('Could not build the stamp XObject');
    // ... 放置它,然后在关闭 Stamp 之前释放它(见下文)...

它的签名是 CreateXObjectFromPage(Source: TPdf; SourcePageIndex: Integer): TPdfXObject。当源文档不是 Active 时这个方法会抛异常,而当 PDFium 构建不出这个对象时它返回 nil 而不是抛异常,所以上面那道显式检查不是可选的。回来的句柄是一个归你所有的 TPdfXObject,而附在它身上的两条生命周期约束,是整件事里最容易把人绊倒的部分,所以它们在下面单独占一节

把图章放到页面上

一个捕获好的 XObject 自己什么也不做。要让它出现,你得用 InsertFormObjectFromXObject 把它的一份副本插到文档的当前页上,也就是由 1 起始的 PageNumber 属性选中的那一页。那个调用返回底层的页面对象,一个 FPDF_PAGEOBJECT,而返回的句柄就是你用来定位这次放置的东西。没有变换时,图章会落在源页面自己坐标系的原点上,而那很少是你想要的位置

因为 InsertFormObjectFromXObject 每调用一次插入一份副本,并且每次交回一个全新的页面对象,你可以用不同的变换把同一个 XObject 在一页上画好几遍,而被存起来的内容在文件里仍然只算一次。一个角落 logo 和一个淡淡的整页水印,可以出自同一个捕获对象

var
  PageObj: FPDF_PAGEOBJECT;
  M: TPdfMatrix;
  RawM: FS_MATRIX;
begin
  // Dest 的当前页接收这个 XObject 的一份副本
  PageObj := Dest.InsertFormObjectFromXObject(XObject);
  if PageObj = nil then
    raise Exception.Create('Insert failed on this page');

  // 定位它:右移 200 单位、上移 500 单位,缩放到 70%
  M := TPdfMatrix.Create;
  try
    M.Scale(0.7, 0.7);
    M.Translate(200, 500);
    RawM := M.Handle;
    if FPDFPageObj_SetMatrix(PageObj, RawM) = 0 then
      raise Exception.Create('Cannot assign the stamp matrix');
  finally
    M.Free;
  end;
  Dest.UpdatePage;   // 把这一页的编辑提交进它的内容流
  // 每一页都做完之后:if not Dest.SaveAs(...) then ...
end;

有两个杂务细节让这件事变得安全。第一,一旦插入,那个页面对象就属于页面,而不属于 XObject。之后释放 XObject 不会让你已经做好的那些放置失效。这正是下面描述的"捕获-放置-释放"次序能成立的原因。第二,插入和定位只改变页面在内存中的对象列表;UpdatePage 才是把那份列表序列化回页面内容流的那一步,所以一个你编辑过却没调用它的页面,保存出来就像图章从来没放上去过

那条会咬人的句柄生命周期规则

有两条约束管着这个 XObject 句柄,无视其中任何一条,产生的故障看上去都和它的成因毫不相干。第一,在你调用 CreateXObjectFromPage 的那一刻,源文档必须是活动的。这次捕获是从活着的源文档里读源页面的内容,所以句柄构建时那份文档和它的页面都必须是打开且有效的。第二,也是让人意外的那一条:这个句柄必须在源页面被关闭之前释放,实践中也就是在你关闭或释放它所来自的源文档之前

原因在于这个 XObject 是一个指进源文档仍然拥有的结构的引用。它不是一份可以在源消失之后随身携带的、独立自足的副本。先把源关掉,句柄就指向了已经被拆掉的内容,于是之后释放它、或者对它做任何别的使用,操作的都是不再有效的内存。症状是悬空句柄的经典表现:关闭时的一次访问违规,或者随分配顺序四处游走的间歇性损坏,而调用栈指向的是清理代码,不是真正引发问题的那一行。修法是次序,不是防御性编码。构建 XObject,把它插到每一个需要它的页面上,释放 XObject,然后才关闭源文档。TPdfXObject 的析构函数会替你释放底层的 PDFium 句柄,所以在正确的时机释放这层包装就是你全部的责任

PDFium 页面图章的次序化生命周期示意图:捕获、放置、释放 TPdfXObject 句柄,最后才关闭图章文档
把图章捕获一次、放到每一页上,趁图章文档还开着时释放 XObject,最后再保存并关闭源文档

矩阵,以及它那六个数字的含义

放置是一次二维仿射变换,与 PDF 在各处用来定位内容的那一个相同(ISO 32000-1,8.3.4 节)。它是六个数字,写作 a, b, c, d, e, f,PDFium 把它们暴露为 FS_MATRIX 记录。它们把一个点从对象自己的空间映射到页面空间:

FS_MATRIX 结构剖析:PDFium 用来对页面上盖好的 Form XObject 做缩放、旋转和平移的六个仿射系数
六个数字把图章坐标映射进页面空间,而 TPdfMatrix 按调用顺序把 Scale、Rotate 和 Translate 组合起来,摆好一个斜向水印
// x' = a*x + c*y + e
// y' = b*x + d*y + f
//
// a, d :水平和垂直方向的缩放
// b, c :错切 / 旋转项
// e, f :平移(原点落在页面上的哪里)

你可以手工填那六个值,但手工把它们组合起来正是旋转出错的地方,因为旋转会把 a, b, c, d 四个全搅在一起。来自 FPdfMatrix 单元的 TPdfMatrix 包装替你组合常见操作,并且边走边右乘,所以 TranslateScaleRotate 按你调用的顺序串起来。一个斜向水印是先旋转再平移把它重新居中;一个角落 logo 是先缩放再平移。矩阵准备好之后,把它的原始值——类型为 FS_MATRIXHandle 属性——复制到一个局部变量里,再把那个变量传给 FPDFPageObj_SetMatrix;导入声明把矩阵声明成了 var 参数,所以属性不能直接交给它,而它失败时的返回值是 0。当你宁愿直接传数字而不想构建一个包装时,还有更底层的 FPDFPageObj_Transform 可用,它直接接受六个 double 值

按正确的次序给每一页盖章

完整的写法把各个零件按生命周期规则要求的次序拼在一起。打开两份文档,把图章捕获一次,依次设置 1 起始的 PageNumber 走过目标文档的各页、插入并定位一份副本、用 UpdatePage 提交每一页,然后释放 XObject,再用 SaveAs 保存,最后才让源文档关闭

procedure StampEveryPage(const ASource, AStamp, AOutput: string);
var
  Dest, Stamp: TPdf;
  XObject: TPdfXObject;
  PageObj: FPDF_PAGEOBJECT;
  M: TPdfMatrix;
  RawM: FS_MATRIX;
  I: Integer;
begin
  Dest := TPdf.Create(nil);
  Stamp := TPdf.Create(nil);
  try
    Dest.FileName := ASource;
    Dest.Active := True;
    Stamp.FileName := AStamp;
    Stamp.Active := True;
    if not (Dest.Active and Stamp.Active) then
      raise Exception.Create('Could not open the input documents');

    // 1. 把图形捕获一次。此处 Stamp 是 Active 的
    XObject := Dest.CreateXObjectFromPage(Stamp, 0);
    if XObject = nil then
      raise Exception.Create('Could not capture the stamp page');
    try
      // 2. 在 Dest 的每一页上放一份副本。PageNumber 从 1 起算
      for I := 1 to Dest.PageCount do
      begin
        Dest.PageNumber := I;                // 让第 I 页成为当前页
        PageObj := Dest.InsertFormObjectFromXObject(XObject);
        if PageObj = nil then
          Continue;

        M := TPdfMatrix.Create;
        try
          M.Rotate(45);                      // 斜向水印
          M.Translate(150, 100);             // 微调到位
          RawM := M.Handle;
          FPDFPageObj_SetMatrix(PageObj, RawM);
        finally
          M.Free;
        end;
        Dest.UpdatePage;                     // 提交这一页的编辑
      end;
    finally
      XObject.Free;                          // 3. 在 Stamp 关闭之前释放
    end;

    // 4. 趁 Dest 还开着把结果写出去
    if not Dest.SaveAs(AOutput) then
      raise Exception.Create('Could not save ' + AOutput);
  finally
    Stamp.Free;                              // 源最后关闭
    Dest.Free;
  end;
end;

那些 try 块的形状才是真正干活的地方。内层的 finally 会在控制流可能抵达释放 Stamp 的外层 finally 之前就把 XObject 释放掉,所以即便循环中途抛出异常,句柄也总是在它的源还活着的时候被释放。把这层嵌套写对,生命周期规则就自己照顾好自己了

盖章只是一整套构建与编辑页面内容工具的一个角落。如果你的图章本身是一张图像而不是一个捕获来的页面,用 PDFium 把图像转换成 PDF 文档讲的是先把那张位图弄进文档。而当你想随着可见图章一起携带的是一个文件而不是页面上的墨迹时,在 Delphi 中处理 PDF 附件展示了嵌入文件那一面。这一切都随面向 Delphi 与 C++Builder 的 PDFium Component 一同发布,同行的还有本博客别处讲到的渲染、编辑和文档 API