Technical Article

Delphi 中的 HotPDF TextOut:大小、样式、旋转和间距

HotPDF 文档中的每一个可见字符串都是通过一次调用生成的:TextOut(X, Y, angle, Text)。Hello World 示例使用它最简单的形式,字体只设置了一次,四个参数保留为合理的默认值。过了这第一页,同样的四个参数就承载了布局的全部重量。第三个参数旋转文本段。紧随其前的字体设置决定了大小和样式。而从页面角以磅为单位测量的 X、Y 坐标对,是横亘在干净的报告和会在别人的打印机上重叠、剪裁或向下漂移一行的文本之间的唯一屏障。这就是 TextOut 发挥作用的地方,也是默认设置不再足够的地方

在此之前,牢记其签名是值得的:XY 是以磅为单位的 Single 类型,angle 是以度为单位的 Extended 类型,而 TextWideString,所以 Unicode 可以直接穿透而无需单独调用。第二个重载接受一个 PWORD 和一个长度,用于当您已经拥有字形代码时,但对于普通的字符串,WideString 形式是您的首选

大小和样式来自 SetFont,而不是 TextOut

TextOut 没有尺寸参数。大小、粗细、倾斜,这一切都存在于执行前的 SetFont 调用中,并且一直有效,直到下一个 SetFont 替换它。这一个事实解释了大多数第一天遇到的困惑:某行变成了粗体,因为前三次调用中某处设置了 [fsBold] 并且没有任何操作清除它

Pdf.CurrentPage.SetFont('Times New Roman', [], 24);
Pdf.CurrentPage.TextOut(72, 740, 0, 'Quarterly Report');        // 24pt regular

Pdf.CurrentPage.SetFont('Times New Roman', [fsBold], 12);
Pdf.CurrentPage.TextOut(72, 712, 0, 'Revenue');                 // 12pt bold

Pdf.CurrentPage.SetFont('Times New Roman', [fsItalic], 11);
Pdf.CurrentPage.TextOut(72, 694, 0, 'figures in thousands');    // 11pt italic

Pdf.CurrentPage.SetFont('Courier New', [fsBold, fsItalic], 10);
Pdf.CurrentPage.TextOut(72, 676, 0, '  +18.4% YoY');            // styles combine

第二个参数是 TFontStyles 集合,因此 [fsBold, fsItalic] 是粗斜体,[] 是纯文本。大小以磅为单位,与坐标单位相同,这使得垂直间距很容易推理:12 磅的行大致需要 14 到 16 磅的垂直步进空间来呼吸,所以每行将 Y 下降 14 磅是一个合理的起始行距。没有自动换行机制。您需自己计算每个基线,这对于段落来说很繁琐,但对于表单来说却很精确,因为表单上的每个字段都位于固定的坐标处

关于字体名称的两个实用提示。它是针对构建机器上安装的字体进行解析的,操作系统返回的内容就是被嵌入的内容,因此在您的桌面上解析的名称与在构建服务器上解析的名称不能保证是同一种字体。而且字体必须涵盖字符串中的脚本。仅有拉丁文的字体下运行西里尔文或 CJK 文本会渲染为缺少的字形方块且没有错误提示,这就是为什么 Hello World 页面在混合语言时会使用广泛的 Unicode 字体

HotPDF TextOut 页面,展示在不同字符集下使用常规、粗体和斜体样式渲染的 Arial、Times New Roman 和 Courier New 字体

角度参数围绕锚点旋转

第三个参数是大多数代码永远留为零的参数。传递一个非零值,文本段将围绕其自身的 (X, Y) 锚点(文本的左下角)逆时针旋转该度数。锚点本身不会移动,因此放置水平标签的相同坐标可以放置其旋转后的双胞胎;仅仅是字形的行进方向改变了

Pdf.CurrentPage.SetFont('Arial', [fsBold], 11);

// A vertical axis label down the left margin: 90 degrees reads bottom-to-top.
Pdf.CurrentPage.TextOut(40, 300, 90, 'Units sold');

// A diagonal DRAFT watermark across the page body.
Pdf.CurrentPage.SetFont('Arial', [fsBold], 60);
Pdf.CurrentPage.TextOut(150, 250, 45, 'DRAFT');

// Column headers tilted 60 degrees so long labels fit a narrow table.
Pdf.CurrentPage.SetFont('Arial', [], 9);
Pdf.CurrentPage.TextOut(120, 600, 60, 'Q1 actual');
Pdf.CurrentPage.TextOut(160, 600, 60, 'Q2 actual');

九十度是常见情况,如图表侧面的向上标签或书脊标题。四十五度处理倾斜的列标题,这个技巧可以让较宽的标签位于较窄的列上方,而不会溢出到邻近的列中。旋转不会改变锚点的解释方式,这往往会绊倒人们:90 度的文本段仍然从 (X, Y) 开始并从那里向上生长,所以为了居中旋转标签,你需要调整锚点,而不是角度。当几个旋转的文本段共享一个基线时,给它们相同的 Y 值并递增 X 值,就像你为堆叠的水平线递减 Y 值那样

无需猜测的坐标定位

坐标是要么通过审查、要么悄然失败的部分。HotPDF 从页面的左下角进行测量,Y 轴向上增长,单位为磅,每英寸 72 磅。美国信纸(US Letter)页面为 612 乘 792 磅;A4 为 595 乘 842 磅。因此,在 Letter 上设置一英寸的顶部边距,会将您的第一个基线置于 Y = 792 减 72 减去字体大小附近,而不是靠近顶部的一个小数字。任何习惯于屏幕坐标(Y 从零向下增长)的人,都会把第一行写到页面底边缘外,然后花十分钟想知道它去哪儿了

将布局视为基于命名锚点的算术,而不是一列魔法数字。左边距、每行递减的运行基线以及固定的行距,可以将一段标签变成一个简短的循环,而不是满墙的字面量:

const
  LeftMargin = 72;        // 1 inch in
  TopBaseline = 720;       // first line, ~1 inch down on Letter
  Leading = 16;            // vertical step between lines
var
  Y: Single;
  Line: string;
begin
  Pdf.CurrentPage.SetFont('Arial', [], 11);
  Y := TopBaseline;
  for Line in ReportLines do
  begin
    Pdf.CurrentPage.TextOut(LeftMargin, Y, 0, Line);
    Y := Y - Leading;
    if Y < 72 then            // bottom margin reached
    begin
      Pdf.AddPage;
      Pdf.CurrentPage.SetFont('Arial', [], 11);  // font resets on a new page
      Y := TopBaseline;
    end;
  end;
end;

分页守卫是大家最容易忘记、但在实际应用中引发问题最严重的一行代码。TextOut 下面没有流式布局。递减超过下边距后,文本将继续绘制到边距外、页面外、甚至消失,且没有任何警告。因此,您必须自己监视 Y,当它越过底线时调用 AddPage,然后重置基线。AddPage 之后的 SetFont 并非可有可无的填充物:当前字体无法在分页后保留,如果您跳过这一步,新页面上的首段文本将以查看器的默认字体显示

用于自适应和对齐的字符和单词间距

有时字符串是正确的,但宽度不对:必须跨越固定标尺的标题、应该让数字读起来更宽松的代码、或者需要微调数值对齐的列。PDF 提供了两个用于此目的的文本状态运算符,字符间距(Tc,在每个字形后添加的额外空间)和单词间距(Tw,在每个空格字符处添加的额外空间),两者均以未缩放的文本空间单位表示,实际上就是当前字体大小的磅值。它们是状态,而不是传递给 TextOut 的参数,因此你需要设置它们,进行绘制,然后再将其重置

// Letter-space a short heading so it stretches across a rule.
Pdf.CurrentPage.SetCharacterSpacing(4);
Pdf.CurrentPage.SetFont('Arial', [fsBold], 14);
Pdf.CurrentPage.TextOut(72, 740, 0, 'S U M M A R Y');
Pdf.CurrentPage.SetCharacterSpacing(0);   // reset before normal body text

// Open up the gaps between words on a single wide line.
Pdf.CurrentPage.SetWordSpacing(6);
Pdf.CurrentPage.SetFont('Arial', [], 11);
Pdf.CurrentPage.TextOut(72, 712, 0, 'Name        Department        Extension');
Pdf.CurrentPage.SetWordSpacing(0);

单词间距仅对空格字符(代码 32)起作用,这带来了一个值得了解的后果:它在没有 ASCII 空格的 CJK(中日韩)文本段中没有任何作用,而且它与编码为字形索引而不是字节的文本交互起来很奇怪。对于拉丁文表格输出,它是在不重新输入字符串的情况下拉宽间隙的廉价方法。字符间距对于必须达到目标宽度的标题来说是更好的工具,因为它将调整均匀地分布在每个字形上,而不是集中在空格处

重置是贯穿始终的纪律。间距,像字体一样,是页面绘制状态的一部分,且状态将一直保持直到你更改它。对一个标题进行了字母间距拉伸而忘记将其清零,那么下面所有的段落都将继承这种拉伸效果,这会被解读为一种微妙的、难以定位的错误,它能逃过漫不经心的校对,但在仔细检查时就会原形毕露。可靠的习惯是设置一个间距值,绘制需要该值的文本段,然后立即在下一行将其重置为零,这样后续的代码就不必知道前面的部分做了什么

HotPDF TextOut 页面,对比了水平文本缩放、字符间距、单词间距,以及填充与描边渲染模式

在实际出问题的地方检查输出

文本布局在第二台机器上会失败,而不是在第一台机器上,因此真正重要的检查都发生在离开你办公桌的地方。在一个没有安装你的开发者字体集的系统上打开生成的文件,并确认嵌入的字体仍然能够正常渲染,包括带有重音的拉丁字母、任何非拉丁字符,以及标点符号,要一次性进行全面检查,而不是抽查那些简单的字符。选择并复制几行,以确认文本是真正的文本而不是轮廓,当涉及到搜索或提取时,这就显得尤为重要。用具有代表性的数据(最长的德文标签和最宽的数字)提供给布局,而不是整洁的占位符,因为溢出字段的文本段总是你没有亲手输入的那个。而且如果页面必须打印在预先印好的表格上,请打印或光栅化一个样本并将其与原件对比;四分之一毫米的基线漂移在屏幕上是不可见的,但在纸上却很明显

如果您还没有写过一页代码,请从 HotPDF Hello World 示例 开始,它设置了文档、字体以及上述所有内容所依赖的左下角坐标系。这里展示的 TextOutSetFont 和间距调用是适用于 Delphi 和 C++Builder 的 HotPDF 组件的一部分