技术文章

在 Delphi 中测量 PDF 文本用于布局与自动换行

绘制一段文本的调用本身很直接:你给 AddText 传入字符串、字体、字号和位置,字形就会出现。但它不会告诉你绘制后这段字符串会有多宽,也不会自动在边界处断行。一次 AddText 总是把文本放在一个位置。如果字符串比预期列宽更宽,它只会跑出边缘,绘制调用本身不会发警告。要想从单行标签变成一段文字,缺的是“在开始绘制前知道字符串在所选字体与字号下的宽度”

这就是典型布局问题。要把段落按列装配,必须逐词判断候选行是否适配每次换行点,而这一切必须在绘制前完成。自动换行是围绕绘制调用的测量循环,而一个只负责绘制的绑定只给你一半答案。PDFium 组件提供了 MeasureTextMeasureTextWidth 两个函数,能在不改动页面内容的情况下返回字符串的渲染范围,补齐这一缺口

为什么用类助手而不是给 TPdf 增新方法

测量能力以 Delphi 类助手的形式附着在 TPdf 上,并在独立单元中实现,不直接改写 TPdf 的定义。这是一个语法机制:在单元导入作用域后,新增方法像原生成员一样可直接调用,因此你会看到 Pdf.MeasureTextWidth(...),而不需要构造或传入额外对象

这样设计的原因在于关注点隔离。核心 TPdf 保持原样,不新增字段、也不改动现有签名,没有布局需求的项目不会被迫链接这些代码;需要布局能力的项目只要在 uses 引用该单元即可,这种能力级别的扩展对不拥有或不便改动的类型来说很清晰

uses
  PDFium, FPdfView, FPdfEdit,
  FPdfMeasure;   // the helper unit; brings MeasureText into scope on TPdf

// With the unit in scope the methods read as members of TPdf:
var
  W, H: Double;
begin
  Pdf.MeasureText('Subtotal', 'Helvetica', 11, W, H);
  // W and H are now the rendered width and height in PDF user units
end;

不接触页面的测量方式

测量结果不能带副作用。它只负责返回宽度,不应在页面上留下任何痕迹,因为你会在布局决策期间反复调用,页面在视觉上应与“未测量任何内容”时一致。实现方式是先构建文本对象,再读取其边界,最后在未绑定到页面前销毁,避免污染内容流

这个流程包含四个 PDFium 调用。FPDFPageObj_NewTextObj 在文档中创建文本对象,指定字体名和字号;FPDFText_SetText 写入字符串;FPDFPageObj_GetBounds 读取该对象边界框;FPDFPageObj_Destroy 销毁对象。关键是流程里不包含任何页面插入 API,创建、查询、销毁都在文档之外完成,因此返回后源文档保持不变。它返回的是边界框四个数值,是一次“临时探测”结果

这个路径之所以稳妥,是因为 PDFium 没有公开可直接逐字拼接的便捷字距机制可用。字形度量受字体程序、编码以及 PDFium 加载字体的方式影响,并且没有公开 API 可直接逐字返回步进值。使用真实文本对象的边界框可得到与实际排版一致的结果,因为该边界来自与实际绘制一致的内部布局过程。创建一次临时对象后读取边界,是该库能给的最可靠测量

// The shape of MeasureText, expressed against the verified PDFium calls.
// A text object is built, measured, and destroyed; no page is involved.
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);   // probe discarded, page untouched
  end;
end;

坐标系与返回单位

边界框返回四条边:左、下、右、上,宽高通过相减得出。宽度是右减左,高度是上减下。单位是 PDF 用户单位,一英寸等于 72 单位,这与你在页面上放置文本时使用的坐标系一致。此阶段不会涉及像素或设备单位。一个宽度 36 的值代表 0.5 英寸的页面空间,后续渲染分辨率改变也不影响这个定义

Y 轴在 PDF 坐标里向上递增,这就是为什么要用“上减下”得到高度。把每行高度累加并从基线减去可得下一行起点,因为页面向下移动意味着 Y 变小。若目标是屏幕而非纸张,可按显示分辨率把用户单位转换为像素:像素值 = 单位值 × DPI / 72,因此在决定换行前可以将列宽按点位与实际测量比较

退化输入的处理

这组函数故意“静默失败”。若没有打开文档,或文本对象创建失败,返回的尺寸将是 0,而不会抛出异常。宽高起始值为 0,只在成功读回边界后才会被更新。空字符串、未打开文档、字体无法在 PDFium 中解析为对象,都会返回 0 而非异常

这么做的好处是避免在成千上万词的循环里每步都抛异常,逻辑更轻。代价是调用方要处理该信号:0 宽不是文本真实宽度,而是“无法测量”标记。任何用这个结果参与除法或假设为正值的逻辑都必须先判断。把 0 当成“不可测量”能保持语义明确,反之则可能在大量输入下形成重叠字形列

基于测量的贪心换行

有了长度函数后,自动换行就是一个典型的贪心循环。把段落切词,维护当前行文本;对每个词临时拼上后测量新行宽度,只要未超出列宽就继续追加,超出时将当前行输出到页面,再以溢出的词开始下一行。循环始终以 MeasureTextWidth 驱动,只有验证通过的行才会绘制到页面

procedure WrapParagraph(Pdf: TPdf; const Para, Font: WString;
  FontSize: Single; X, TopY, ColumnWidth, LineHeight: Double);
var
  Words: TArray<string>;
  Line, Trial: WideString;
  I: Integer;
  Y: Double;
begin
  Words := string(Para).Split([' ']);
  Line  := '';
  Y     := TopY;
  for I := 0 to High(Words) do
  begin
    if Line = '' then
      Trial := Words[I]
    else
      Trial := Line + ' ' + Words[I];
    // Measure the candidate line before drawing anything.
    if (Line <> '') and (Pdf.MeasureTextWidth(Trial, Font, FontSize) > ColumnWidth) then
    begin
      Pdf.AddText(Line, Font, FontSize, X, Y);   // flush the line that fit
      Y    := Y - LineHeight;                    // Y decreases going down
      Line := Words[I];                          // overflowing word starts next line
    end
    else
      Line := Trial;
  end;
  if Line <> '' then
    Pdf.AddText(Line, Font, FontSize, X, Y);      // flush the final line
end;

该循环衡量的是整行文本而非逐词求和,因为行宽不是单词宽度简单相加。词间空格也会贡献宽度,而测量一次候选行可直接覆盖这种细节。贪心规则是尽可能保留最后一个适配项作为行尾,与从单次绘制到真实段落排版之间的差距一致。真正复杂的是测量前置流程,绘制调用只是最终执行

内容在工作流中的位置

测量位于“生成内容”与“渲染输出”之间,能自然衔接文档构建流程。如果你是先组建页面和文本,完整的初始文档搭建见 使用 PDFium 组件在 Delphi 中从零创建 PDF 文档,文中介绍了 AddText 与页面设置。如果字体差异会影响测量精度,请参考 使用 PDFium 组件在 Delphi 中分析 PDF 字体属性,其中给出了影响边界框的字体信息。上述内容都与 Delphi 和 Lazarus 的 PDFium 组件 配套,覆盖了文档、页面和文本 API