技术文章

Delphi PDF 中的页面拼接与水印模板

页面拼接把既有页面的内容合成到一块共享画布上,这是与合并文件不同的操作。losLab PDF Library 为 Delphi 实现了 Form XObject 页面拼接和可组合水印模板:StitchPageOverlay、StitchPagesSideBySide 和 StitchPagesVertically 能以任意缩放放置被捕获的页面并保持完整矢量保真度,而 TPDFlibCompositeWatermark 一次调用就能把文字和图像水印叠加到一个页码区间上。如果你真正需要的是把若干文档的整页追加进同一个文件,那属于合并家族,见字节级快速 PDF 合并一文;本文讲的是在同一个坐标空间内组合页面内容

导向这里的诉求平淡而顽固。一所学校想把两页试卷拼版到一张横向纸上,把纸张用量减半。一位本地化审校想让英文页和它的译文并排出现在同一页上,一起滚动。一个法务团队想给每份草稿一次性盖上三层:角上一个 logo、中间一道斜排的 CONFIDENTIAL,再加一行小小的页脚,而且只盖在第 1 到第 3 页。这三件事底下是同一个问题:把已经排好版的页面内容以另一种尺寸放到别处,而不把它降级成位图

为什么直接拼接 PDF 内容流会坏掉?

朴素的拼接,也就是把源页面的 /Contents 流复制进目标页面,会因为三个结构性原因失败。第一,坐标会冲突:两个内容流都假定原点在各自 MediaBox 的左下角,于是被粘贴的内容落在既有内容上面的错误位置。第二,资源名会冲突:每一页都拿自己的 /Resources 字典去解析 /F1 Tf 和 /Im0 Do 这类操作符,而两个页面把同一个名字绑到不同字体或图像上是家常便饭,于是其中一个会悄悄用错误的资源渲染出来。第三,裸流复制根本给不了你任何缩放能力;你能做的顶多是平移

Form XObject 一举解决这三点,这也正是 losLab PDF Library 把拼接建立在它们之上的原因。ISO 32000-1 §8.10 把 form XObject 定义为一个自包含的内容流,带有自己的 /Resources 字典和自己的坐标空间,通过在任意当前变换矩阵下引用其名称而被放入页面。CapturePage 把一整个既有页面,内容加资源,包进一个 /Subtype /Form XObject,随后各拼接函数用一个 cm 矩阵给它定位:缩放、平移、旋转,悉听尊便。由于源保持矢量,文本在任何缩放级别下仍是文本,路径仍是路径,这与让矢量图形与着色图案与分辨率无关的是同一个性质。没有任何东西被光栅化,而独立的资源字典意味着被捕获的页面不会与目标页面争夺 /F1 这个名字

PDF Library for Delphi:朴素复制 PDF 内容流会因坐标冲突、/F1 资源名冲突和无法缩放而失败;而 CapturePage 把每一页包成自包含的 Form XObject,由一个 cm 矩阵放到更宽的目标页上,资源相互隔离且完整保持矢量保真度
复制裸流会同时以三种方式失败,而把每一页捕获为 Form XObject 则给出一个独立的坐标空间与资源字典,只需一个 cm 矩阵就能定位

怎样把两个 PDF 页面合到一页上?

StitchPagesSideBySide(Page1, Page2, Gap, TargetHeight) 是直接的答案:它捕获两个源页面,创建一个更宽的新页面,把两份捕获分别放在左右并在中间留出装订间隙,各自缩放到适配 TargetHeight。函数返回新页面的页码(从 1 开始)或失败时返回 0,而两个源页面在这一操作中都会被捕获并隐藏。一版两页的试卷场景是三行代码的活儿

var
  Lib: TPDFlib;
  NewPage: Integer;
begin
  Lib := TPDFlib.Create;
  try
    Lib.LoadFromFile('exam.pdf', '');
    // 第 1、2 页放到一个更宽的新页面上,中间留 18pt 间隙,
    // 两者都缩放到 595pt 的输出高度(A4 横向)
    NewPage := Lib.StitchPagesSideBySide(1, 2, 18, 595);
    if NewPage > 0 then
      Lib.SaveToFile('exam-2up.pdf');
  finally
    Lib.Free;
  end;
end;

要做纵向合成,StitchPagesVertically(PageRanges, Gap, TargetWidth) 会把一整个页码区间摞成一张海报式的高长页面。PageRanges 接受标准区间语法,比如 '1-3,5'。每个源页面都会被缩放到宽度与 TargetWidth 相符,因此每一页贡献的高度是它自己的高度乘以它自己的缩放系数,而输出页面的高度是这些值之和再加上每对之间的一个 Gap。这正是连续滚动审阅文档,或者由信纸尺寸源拼出的长条小票所需要的形态

Delphi 中的三种 PDF 页面拼接模式:StitchPagesSideBySide 把两份捕获页面以 18 点间隙放到更宽的纸上,StitchPagesVertically 把区间 1-3,5 摞成一张高长页面,StitchPageOverlay 以 0.45 不透明度把源页面绘制到封面上;并提示每次捕获都会隐藏其源页面,使更高的页码整体下移一位
三个拼接调用共享同一套几何:被捕获的页面或并排放置、或纵向堆叠、或绘制到一个既有页面上,而每次捕获都会消耗其源页面,使更高的页码全部下移

怎样把一页叠加到另一页上?

StitchPageOverlay(SourcePage, TargetPage, Left, Top, Width, Height, Opacity) 把一个既有页面绘制到另一个既有页面上,而不是新建一页。源页面被捕获成一个 form XObject,并按你给出的矩形绘制到目标页上,Opacity 取 0 到 1(1 为不透明)。宽和高是自由参数,因此当你给的比例与源页面自身的宽高比不同时,放置结果会被拉伸;想要源页面不变形的缩略图时,请传一对成比例的数值。一种典型用法是:某份文档的审批栏作为独立一页维护,然后盖到封面上

var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    Lib.LoadFromFile('report.pdf', '');
    // 第 5 页存放审批栏;把它放到第 1 页上,
    // 落在一个 260 x 180 的框里,不透明度 45%。第 5 页被消耗掉:
    // 它被捕获并隐藏,因此第 6..n 页变成第 5..n-1 页
    if Lib.StitchPageOverlay(5, 1, 300, 500, 260, 180, 0.45) = 1 then
      Lib.SaveToFile('report-approved.pdf');
  finally
    Lib.Free;
  end;
end;

捕获之后页码会怎样?

CapturePage 会消耗源页面:它的内容被移入 XObject,而现在空掉的那一页被隐藏,于是页码高于源页面的每一页立刻下移一位。这是整个拼接 API 中最重要的一条语义。losLab PDF Library 会在每个拼接调用内部处理好这道算术,StitchPageOverlay 会在目标页位于源页之后时调整目标索引,StitchPagesSideBySide 则先捕获页码较大的那一页,好让第二次捕获的索引仍然有效;但你自己周围的代码必须自行核算调用之间的这次位移。有两条规则能让多次拼接的代码保持正确。任何你之后会用到的页面尺寸或页码,都要在第一次捕获之前记录下来,因为它们随后就变了。而当你自己在循环里捕获多个页面时,请按页码降序迭代,这样隐藏靠后的页面就永远不会扰动你尚未捕获的靠前页面的索引

怎样给一个页码区间施加多层水印?

PDFlibWatermark.pas 中的水印模板层把「定义一次,施加到多页」变成了对象。TPDFlibWatermarkBase 承载所有水印共有的东西:一个九宫格 Position(从 wpTopLeft 到 wpBottomRight,再加上使用原始 X/Y 坐标的 wpCustom)、以度为单位的 Angle、0 到 1 的 Opacity,以及一个 PageRange 字符串,留空表示所有页面。TPDFlibTextWatermark 再加上文本、字体 ID(默认 Helvetica)、字号和一个 RGB 颜色;TPDFlibImageWatermark 则加上一个来自先前所添加图像的图像 ID,外加放置的宽度与高度。TPDFlibCompositeWatermark 持有一个子水印列表并递归施加它们,而 TPDFlib.ApplyWatermark 是那个一次调用的入口,它遵守模板自身的页码区间并返回被盖过的页数。那个三层法务印章长这样

var
  Lib: TPDFlib;
  Logo: TPDFlibImageWatermark;
  Diagonal, Footer: TPDFlibTextWatermark;
  Stack: TPDFlibCompositeWatermark;
begin
  Lib := TPDFlib.Create;
  try
    Lib.LoadFromFile('draft.pdf', '');
    Stack := TPDFlibCompositeWatermark.Create;
    try
      Logo := TPDFlibImageWatermark.Create;
      Logo.ImageID := Lib.AddImageFromFile('logo.png', 0);
      Logo.Position := wpTopRight;
      Logo.Width := 90;
      Logo.Height := 32;
      Stack.Add(Logo);

      Diagonal := TPDFlibTextWatermark.Create;
      Diagonal.Text := 'CONFIDENTIAL';
      Diagonal.Position := wpCenter;
      Diagonal.Angle := 45;
      Diagonal.FontSize := 60;
      Diagonal.Opacity := 0.15;
      Stack.Add(Diagonal);

      Footer := TPDFlibTextWatermark.Create;
      Footer.Text := 'Internal review copy';
      Footer.Position := wpBottomCenter;
      Footer.FontSize := 9;
      Footer.Opacity := 0.6;
      Stack.Add(Footer);

      Stack.PageRange := '1-3';   // 子水印继承这个区间
      Lib.ApplyWatermark(Stack);
      Lib.SaveToFile('draft-marked.pdf');
    finally
      Stack.Free;                 // 组合体会释放它的子水印
    end;
  finally
    Lib.Free;
  end;
end;

有两处组合细节值得讲明。区间继承是单向且非破坏性的:PageRange 为空的子水印在 Apply 期间临时继承组合体的区间,而自行设定了区间的子水印保留自己的设定,因此你可以把页脚钉在每一页上,同时让斜排印章只覆盖草稿章节。而所有权归属于组合体:TPDFlibCompositeWatermark.Destroy 会释放它的子水印,所以你只释放组合体一次,别的都不用管,正如上面的示例

Delphi 中的组合水印模板:TPDFlibWatermarkBase 承载九个 Position 锚点、角度、不透明度和页码区间,TPDFlibCompositeWatermark 叠加一个图像 logo、一层旋转的 CONFIDENTIAL 文字和一层页脚文字,ApplyWatermark 把三层一起盖到第 1-3 页上,释放组合体即释放其子水印
组合体在共享的基类属性之上持有一个图像层、一个斜排文字层和一个页脚文字层,在其 PageRange 上递归施加,并在销毁时释放其子水印

值得知道的边界

有两条诚实的限制,两者都源自这套机制的工作方式。第一,一次捕获只包裹页面的内容流和资源。注释、链接、表单字段和批注是页面字典中的独立对象,不在内容流里,因此它们不会随之进入 XObject;拼接出来的版面带走的是可见的页面内容,而不是其来源的交互层。第二,九宫格文字定位是一种近似:没有完整的排版引擎,库无法知道一个字符串渲染后的确切宽度,所以在解析 wpCenter 这类位置时,它把文本框估算为页面宽度的 40%。这对居中一个短标签来说足够准确,但要对长字符串做像素级精确的放置,请改用 wpCustom 并自己计算坐标

规模在这里不构成实际约束,因为捕获与拼接操作的是对象引用而不是重新编码页面内容;如果你的源文件有数百 MB,那么在拼接之前应先套用大文件合并与拆分的直接访问指南中所述的那套纪律。本文展示的拼接函数与水印模板类随当前版本的 losLab PDF Library 一同发布,支持 Delphi、C# 和 VB.NET;当你需要的版面不在三个预制拼接调用的覆盖范围内时,还可以使用更底层的 CapturePage、DrawCapturedPage 和 DrawCapturedPageMatrix 原语