技术文章

通过 Form XObject 在 PDFium 中实现可复用页面印章

在文档每一页上盖水印或徽标,看起来像是五分钟就能做完的事,直到你用文件体积分析器打开结果才会看见代价。最直接的做法是逐页遍历,然后在每一页上重新构建相同的文本或图像对象。这在视觉上没问题,但浪费会不断累积。一个斜放的 DRAFT 水印如果直接画在一份百页报告上,就会把同一组路径和文本数据复制一百遍,而保存后的文件会把这些内容全部带上

Form XObject 正是 PDF 用来避免这种重复的结构。它把一段可复用内容,无论是整页还是一个小模板,封装成单一命名对象,之后可以在任意位置反复绘制。内容在文件里只保存一次。需要印章的页面只保留一条很短的指令,说明要在这里绘制该 XObject,并使用这个变换。对百页水印来说,文件里新增一个内容对象就够了,而不是新增一百个,这就是文档大小随页数线性增长与否的区别

为什么一个存储对象胜过一百次重绘

这种节省是结构性的,不是表面的。PDF 页面通过执行内容流来渲染,也就是一串绘图操作符。若你每页都重新绘制印章,就等于把整段操作序列都追加到每页的内容流里,字节数会随着页数成倍重复。Form XObject 把这些操作符移到一个只存一次的流里。单页保留的引用很小,只需要压入变换矩阵、调用 XObject、再恢复状态。页数不再把这段图形内容的成本成倍放大

当印章本身很重时,这种差异最明显。一个包含数百段路径的矢量印章,或者一个徽标位图,存储开销都不小。把它存一次再引用后,重的那部分只付一次成本,逐页额外付出的只是几个字节的调用指令。页面上的视觉效果与直接重绘完全一样,这正是重点。读者看不出区别,文件大小却能明显区分

将页面捕获为 XObject

PDFium 会从已有页面构建可复用对象。源可以是你已经打开的某个文档中的一页,也可以是一个只包含水印素材的单页 PDF,或者更大文件中的某一页。CreateXObjectFromPage 会把源页面的内容捕获为一个可复用句柄,这个句柄归目标文档所有,也就是你正在盖章的那个文档

var
  SourceDoc: TPdf;
  Stamp: TPdfXObject;
begin
  SourceDoc := TPdf.Create(nil);
  try
    SourceDoc.LoadFromFile(SourcePath);
    if not SourceDoc.Active then
      raise Exception.Create('Source document could not be opened');

    Stamp := SourceDoc.CreateXObjectFromPage(0);
    if Stamp = nil then
      raise Exception.Create('Could not create XObject from source page');

    try
      // use Stamp here
    finally
      Stamp.Free;
    end;
  finally
    SourceDoc.Free;
  end;
end;

它的签名是 CreateXObjectFromPage(Source: TPdf; SourcePageIndex: Integer): TPdfXObject。如果源文档没有处于 Active 状态,方法会抛出异常;而当 PDFium 无法构建对象时,它会返回 nil 而不是抛异常,所以前面的显式判空并不是可有可无。返回的 TPdfXObject 由你持有,但它的生命周期有两个很容易踩坑的约束,下面会单独说明

在页面上放置印章

捕获到的 XObject 本身不会做任何事。要让它显示出来,必须用 InsertFormObjectFromXObject 把它的一个副本插入到文档当前页,也就是由 1 起始的 PageNumber 属性所指向的页面。这个调用会返回底层页面对象,也就是 FPDF_PAGEOBJECT,而这个返回值就是你设置位置所用的句柄。如果不设置变换,印章会落在源页面自身坐标系的原点,这通常不是你想要的位置

因为 InsertFormObjectFromXObject 每调用一次就插入一份,并且每次都会返回一个新的页面对象,所以你可以在同一页上以不同变换多次使用同一个 XObject,而文件里仍然只保存一份内容。角落徽标和淡化的整页水印可以来自同一个捕获对象

var
  PageObj: FPDF_PAGEOBJECT;
  M: TPdfMatrix;
  RawM: FS_MATRIX;
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);
    RawM := M.Handle;
    if FPDFPageObj_SetMatrix(PageObj, RawM) = 0 then
      raise Exception.Create('Cannot assign the stamp matrix');
  finally
    M.Free;
  end;
  Dest.UpdatePage;   // commit this page's edits to its content stream
  // if not Dest.SaveAs(...) then ... when every page is done.
end;

真正需要关注的是生命周期,而不是“放在那个坐标上”这一句。页面对象插入后就归属于页面,后续释放 XObject 不会影响该页面上的实例,这使得“创建-放置-再销毁句柄”成为可行顺序。只要不重排页面对象,位置信息已落在页面内容流里,后续就能正常持久化

容易踩坑的句柄生命周期规则

XObject 的两个约束不可打乱。第一,调用 CreateXObjectFromPage 时源文档必须仍 Active。函数读取源页内容,源文档与页必须在构建句柄时打开且有效。第二,也是更容易踩坑的,是句柄必须在源文档关闭前释放,实践中也就是在释放源文档之前释放句柄

原因在于 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
// Use the matrix to scale, rotate, and translate a stamp
// before inserting it on the page.

你可以手动填写这六个值,但把它们手动组合起来时,旋转最容易出错,因为旋转会把 abcd 四个值都搅在一起。来自 FPdfMatrix 单元的 TPdfMatrix 包装器会帮你组合常见操作,并按调用顺序进行后乘,所以 TranslateScaleRotate 可以按你调用它们的顺序串起来。对角水印通常是先旋转,再平移回中心;角落徽标通常是先缩放,再平移。矩阵准备好后,把它的原始值,也就是类型为 FS_MATRIXHandle 属性,复制到一个局部变量里,再传给 FPDFPageObj_SetMatrix;这个导入把矩阵声明为 var 参数,所以属性不能直接传进去,而且它在失败时返回 0。如果你更愿意直接传数字而不是先构建包装器,接受六个 doubleFPDFPageObj_Transform 也可以用

按正确顺序给每一页盖章

完整流程把前面的各个部分按句柄生命周期要求的顺序组合起来。先打开源文档和目标文档,只捕获一次印章;然后逐页遍历目标文档,依次设置 1 起始的 PageNumber,插入并定位一个副本,每一页都用 UpdatePage 提交;最后释放 XObject,再用 SaveAs 保存,源文档则放到最后关闭

procedure StampEveryPage(const ASource, AStamp, AOutput: string);
var
  SourceDoc, DestDoc: TPdf;
  Stamp: TPdfXObject;
  PageIndex: Integer;
  PageObj: FPDF_PAGEOBJECT;
  Matrix: TPdfMatrix;
begin
  SourceDoc := TPdf.Create(nil);
  DestDoc := TPdf.Create(nil);
  try
    SourceDoc.LoadFromFile(ASource);
    DestDoc.LoadFromFile(AStamp);

    Stamp := SourceDoc.CreateXObjectFromPage(0);
    if Stamp = nil then
      raise Exception.Create('Could not create XObject from source');

    try
      for PageIndex := 1 to DestDoc.PageCount do
      begin
        DestDoc.PageNumber := PageIndex;

        PageObj := DestDoc.InsertFormObjectFromXObject(Stamp);
        if PageObj = nil then
          raise Exception.Create('Could not insert XObject');

        Matrix := TPdfMatrix.Create;
        try
          Matrix.Translate(36, 36);
          Matrix.Rotate(-30);
          Matrix.Scale(0.9, 0.9);
          if FPDFPageObj_SetMatrix(PageObj, Matrix.Handle) = 0 then
            raise Exception.Create('Could not set matrix');
        finally
          Matrix.Free;
        end;

        DestDoc.UpdatePage;
      end;
    finally
      Stamp.Free;
    end;

    DestDoc.SaveAs(AOutput);
  finally
    DestDoc.Free;
    SourceDoc.Free;
  end;
end;

try 结构的编排才是真正起作用的部分。内层 finally 会在循环结束前或者异常发生时先释放 XObject,确保在源文档关闭之前它的引用始终有效。只要这个嵌套顺序写对了,生命周期规则就会自己成立

印章只是更大一套页面内容构建与编辑工具中的一个环节。如果你的印章本身是一张图像,而不是捕获的页面,在 Delphi 中使用 PDFium 将图像转换为 PDF 文档 会介绍如何先把位图放进文档。若你想随可见印章一起携带的是文件而不是页面上的墨迹,在 Delphi 中使用 PDF 进行附件处理 会展示嵌入文件这一侧。所有这些都随用于 Delphi 和 C++Builder 的 PDFium 组件 一起提供,也包括本博客其他地方介绍的渲染、编辑和文档 API