Technical Article

在 Delphi 中测量 PDF 文本以进行布局和自动换行

将文本放置在 PDF 页面上的调用非常简单。您只需向 AddText 提供字符串、字体、大小和位置,字形就会显示出来。但它不会告诉您该字符串被绘制后会有多宽,也不会将长字符串折断成多行。单次调用会在一个位置绘制一段文本。如果这段文本比您打算让它适应的列还要宽,它只会超出边缘,而绘制调用不会发出任何警告。当您需要一个段落而不是单个标签时,缺失的环节就是在将字符串提交到页面之前,测量所选字体和大小的字符串宽度

这是经典的布局问题。为了将段落换行到一列中,您必须逐词了解每个候选行将占用多少水平空间,并且必须在绘制任何内容之前就了解。自动换行是围绕绘制调用的测量循环,仅进行绘制的绑定为您提供了后半部分。PDFium Component 中的文本测量支持通过两个函数 MeasureTextMeasureTextWidth 填补了这一空白,这两个函数报告字符串的渲染范围,而无需在任何页面上留下标记

为什么测量是类助手(class helper),而不是 TPdf 上的新方法

测量支持作为 TPdf 的 Delphi 类助手提供,位于其自己的单元中,而不是作为硬塞进 TPdf 类的新方法。类助手是一项语言功能,允许您从类声明的外部将方法附加到现有类型。一旦该单元进入作用域,调用新方法就完全如同它们属于该类一样,因此助手方法的调用方式为 Pdf.MeasureTextWidth(...),无需构造或传递单独的对象

以这种方式分层的原因在于分离。核心的 TPdf 类型保持原样,未添加任何字段,也未触及任何现有签名,因此从不需要布局的项目永远不会附带测量代码。确实需要的项目在 uses 子句中添加一个单元,这些方法就会生效。功能变为在单个单元粒度上的可选加入,这是扩展您不拥有或不想干扰的类型的最清晰的方式

uses
  PDFium, FPdfView, FPdfEdit,
  FPdfMeasure;   // 助手单元;将 MeasureText 引入 TPdf 的作用域

// 将单元引入作用域后,这些方法将被视作 TPdf 的成员:
var
  W, H: Double;
begin
  Pdf.MeasureText('Subtotal', 'Helvetica', 11, W, H);
  // W 和 H 现在是 PDF 用户单位中的渲染宽度和高度
end;

不接触页面的测量

测量必须没有副作用。它必须在不留下任何痕迹的情况下报告宽度,因为您在决定布局时会多次调用它,而页面的外观必须与您根本没有测量时完全相同。实现这一点的技术是构建一个文本对象,询问其大小,然后在它被附加到页面之前将其丢弃

这个序列是四个 PDFium 调用。FPDFPageObj_NewTextObj 根据字体名称和大小针对文档创建一个文本对象。FPDFText_SetText 设置该对象承载的字符串。FPDFPageObj_GetBounds 读回对象的边界框(bounding box)。FPDFPageObj_Destroy 释放对象。至关重要的是,该序列中没有任何调用涉及页面插入 API。对象的创建、查询和销毁都是隔离进行的,因此函数返回时文档未改变。它是一个一次性探针,其唯一输出就是边界框的四个数字

这是进行此操作的稳健方式,因为 PDFium 不会暴露您可以自己方便地求和的每个字形步进宽度(advance width)。字形度量取决于字体程序、编码以及 PDFium 加载字面的方式,而且没有公开调用会直接告诉您字符串中每个字符的步进值。另一方面,真实文本对象的边界框是由本应为绘制进行字形布局的同一套机制计算出来的,因此它反映了实际的渲染范围而不是近似值。构建一个一次性对象并读取其边界,是库能提供的最可靠的测量

// MeasureText 的形式,基于经过验证的 PDFium 调用表达。
// 构建、测量并销毁文本对象;不涉及页面。
procedure TPdfMeasureHelper.MeasureText(const Text, Font: WString;
  FontSize: Single; out Width, Height: Double);
var
  TextObject: FPDF_PAGEOBJECT;
  L, B, R, T: Single;
begin
  Width  := 0;
  Height := 0;
  if Self.Document = nil then
    Exit;
  TextObject := FPDFPageObj_NewTextObj(Self.Document,
    FPDF_BYTESTRING(AnsiString(Font)), FontSize);
  if TextObject = nil then
    Exit;
  try
    if FPDFText_SetText(TextObject, FPDF_WIDESTRING(WideString(Text))) = 0 then
      Exit;
    if FPDFPageObj_GetBounds(TextObject, L, B, R, T) <> 0 then
    begin
      Width  := R - L;
      Height := T - B;
    end;
  finally
    FPDFPageObj_Destroy(TextObject);   // 探针被丢弃,页面未受触碰
  end;
end;

坐标与结果的单位

边界框以四个边缘返回,即左、下、右和上,两个维度通过减法得出。宽度为右减左,高度为上减下。两者均以 PDF 用户单位表示,其中一个单位是一英寸的七十二分之一,这与您在页面上定位文本的坐标空间相同。在此阶段没有隐藏的设备单位,也不涉及像素。36 的宽度意味着半英寸的页面,无论最终的渲染分辨率如何

垂直轴的方向与 PDF 的定义一致,Y 值向上增加,这也是为什么高度是上减下而不是相反的原因。当您沿着列向下移动光标时,这个细节很重要。您测量一行的高度,然后从当前基线减去它以找到下一行,因为沿着页面向下移动意味着向更小的 Y 值移动。如果您的目的地是屏幕而不是纸张,您可以使用显示分辨率将用户单位转换为设备像素:用户单位中的值乘以 DPI 再除以 72 即可得出像素,因此,在您决定断行位置之前,您可以将以点数设置的列宽与测量的段落进行匹配

出现退化输入时会发生什么

这些函数的编写方式是使其安静地失败。如果没有打开的文档,或者无法创建文本对象,结果将是零范围而不是引发异常。宽度和高度在顶部被初始化为零,并且仅在成功读回边界框后才会被覆盖。空字符串、缺失的文档、库无法解析为对象的字体,所有这些都返回零而不是抛出异常

这种选择保持了测量循环的简单性,因为遍历数千个单词的循环不适合在每次迭代中都进行异常处理。代价是调用者承担了检查责任。零宽度是一个标记(sentinel),而不是关于文本的事实,因此除以测量宽度或假设其为正值的代码在信任它之前必须防范零值。将零视为“无法测量”,合同就很明确;忽略它,退化的输入就会悄无声息地变成带有重叠字形的列布局

基于测量构建贪婪自动换行

有了宽度函数,自动换行就是一个简短的贪婪循环。您将段落拆分为单词,保留当前行,对于每个单词,您测量如果附加该单词则行的宽度。只要试探行仍然适合列宽,您就继续添加;当它即将溢出时,您使用 AddText 冲洗(flush)当前行,并用不适合的那个单词开始新行。累加完全使用 MeasureTextWidth 完成,唯一到达页面的是您已经确认适合的行

procedure WrapParagraph(Pdf: TPdf; const Para, Font: WString;
  FontSize: Single; X, TopY, ColumnWidth, LineHeight: Double);
var
  Words: TArray<WideString>;
  Line, Trial: WideString;
  I: Integer;
  Y: Double;
begin
  Words := WideString(Para).Split([' ']);
  Line  := '';
  Y     := TopY;
  for I := 0 to High(Words) do
  begin
    if Line = '' then
      Trial := Words[I]
    else
      Trial := Line + ' ' + Words[I];
    // 在绘制任何内容之前测量候选行。
    if (Line <> '') and (Pdf.MeasureTextWidth(Trial, Font, FontSize) > ColumnWidth) then
    begin
      Pdf.AddText(X, Y, Font, FontSize, Line);   // 冲洗适合的行
      Y    := Y - LineHeight;                    // 向下 Y 值减小
      Line := Words[I];                          // 溢出的单词作为下一行的开始
    end
    else
      Line := Trial;
  end;
  if Line <> '' then
    Pdf.AddText(X, Y, Font, FontSize, Line);      // 冲洗最后一行
end;

该循环测量试探行,而不是测量每个单词并求和,因为行的宽度并不是其单词宽度的总和。单词之间的空格也有贡献,测量的段落直接捕获了这一点。贪婪规则——尽可能放入列允许的单词并在适合的最后一个单词处断开——与填补原始 AddText 和真实段落之间差距的规则相同。绘制调用从来都不是难点。必须先于它的测量才是,而这恰好是助手所提供的

这适用在何处

测量是生成内容和渲染内容之间的层,因此它自然地与从头开始的文档工作流的其余部分相匹配。如果您最初是在组装页面并放置文本,其基础在在 Delphi 中使用 PDFium Component 从头创建 PDF 文档中有说明,其中全面涵盖了 AddText 和页面设置。当您测量的字体与字符串同样重要时(因为度量取决于字面),在 Delphi 中使用 PDFium Component 分析 PDF 字体属性展示了库如何报告驱动那些边界框的字体信息。两者都建立在相同的绑定之上,即用于 Delphi 和 Lazarus 的 PDFium Component,测量助手与本博客中描述的文档、页面和文本 API 一起附带在其中