技术文章

使用 HotPDF 在 Delphi 中实现 PDF 文本的两端对齐

两端对齐(Full justification)是一种让列文本在左右边缘都对齐的排版方式,也是我们在印刷书籍或正式报告时期望看到的效果。它描述起来很容易,但出错的几率高得惊人。这是因为对于“多出来的空白该放哪里”这个问题,英文和日文有截然不同的答案,而且如果是采用简单粗暴的方法去测量每一行的宽度,排版速度会非常缓慢。HotPDF 通过一次盒子布局调用即可实现能识别脚本的两端对齐,而该调用的底层隐藏了一项教科书级别的性能修复,值得单独剖析

本文将梳理这两点。首先是在有单词间隙的脚本(scripts)与没有这种间隙的脚本中,负责决定如何分配余白(slack)的排版规则。其次是一项测量算法上的改进,它将每页用于文本对齐的成本降低了约 80 倍,而且输出质量肉眼看不出差别。如果你需要大量生成文档,并希望它们看起来像是由真正的排版工具排版,而不是勉强拉伸等宽文本,那么两者都很重要

两端对齐究竟需要什么

一行以自然宽度绘制的文本,几乎从不会恰好到达其所在的列边界。最后一个字形的末尾与列边界的位置之间总是有一个差值,即余白(slack)。左对齐将余白留在右侧。右对齐将其移到左侧。居中对齐则将其分摊在两端。两端对齐通过将这一行的行距本身加宽,直到两侧边缘都填满方框边界来移除余白;而唯一实在的方法就是从内部拉开字形的间距

区别对齐好坏的规则在于你将余白放在何处。书写时单词之间有空格的脚本,比如英语以及其他拉丁文字家族,在每一个词间空格处都有自然的拼接缝隙。由于读者本来就接受词间空格存在变化,所以拉宽这些空格在视觉上是不可见的。而像汉字、日文假名或韩文谚文等没有词间空格的脚本,也就没有那样的缝隙。在那里,余白必须均匀分布在相邻字形之间,这也就是日本排字工人所称的“均等割付”(kintou-waritsuke),即均等间距。将拉丁式的词间拉伸用于 CJK 行,或者把所有的余白塞进 CJK 行里碰巧包含的唯一空格中,都会产生只有业余输出才会有的空白带与间隙

HotPDF 如何决定空间分配位置

HotPDF 针对每个间隙而不是每一行来做决定。当对齐某一行时,它会遍历每一对相邻的字形,并询问它们之间是否是可拉伸的边界。如果两侧都是空格或制表符,这是拉丁文的场景;或者如果两侧都是可拆分的 CJK 字符,这是均等间距的场景,那么边界就被认为是可拉伸的。它会计算这些边界的数量,将该行的余白均分给它们,并为每个符合条件的间隙加上这份额

结论也是水到渠成。英文行只在单词间距处有可拉伸的边界,所以余白全都落在那里,单词分散开了,而每个单词内的字母保持其自然的间距。一段汉字或假名行中几乎每对字形之间都有一个可拉伸边界,因此,余白会均匀分布在整行文本中,这正是这些脚本所需的字形间均等间距要求。如果在同一行中遇到没有空格的长拉丁单词,由于根本没有可拉伸的边界,HotPDF 就会保留其自然宽度,而不会把它按字母强行拆开。同样,这套逻辑也会直接应用在拉丁文与 CJK 混排的一行文本上,不用为之编写特殊逻辑,因为这个决定仅仅针对每一个具体的边界即可

任何地方都有一个边界会被特意排除。在一行中的最后一个字形之后的位置永远不会被视作间隙,因为在此处拉伸只会又造出一个右侧的差值,这和对齐的初衷背道而驰

为什么最后一行原样保留

段落的最后一行比较特殊,而错误处理它也是对齐过程中最常见的漏洞。段落的最后一行通常较短,往往只有几个词,若为了使其填满整列的宽度而拉伸,会生生地将这几个词拉成横跨页面的稀疏破碎状。正确的排版做法是保持最后一行的自然宽度并让其左对齐

HotPDF 根据位置来检测最后一行。在进行文本折行处理时,引擎知道刚才被截断的这一行是否已经到了字符串的末尾。该最后一行将以普通的左对齐方式原样输出,保持其自然宽度。在它之前的所有行都两边对齐。如果你在文本中写入了硬换行,排版也会严格按指令执行,所以也不会去拉伸那些被刻意写短的行。这为读者呈现的是一个干净的方形文本块,且最后一行能自然收尾,正符合人眼的预期

拖慢两端对齐的测量成本

为了对齐某一行,你必须确切知道它的总宽度以及每一个字形的步进,才能准确地加入那些额外的空白。早期的实现在获取这些数值时采用了一种直接的做法:通过一次完整的 Unicode 宽度查询测出整行文本宽度,再测量一次又一次更长的前缀来反推各个字形的步进。这意味着如果一行有 N 个字形,就会向测量引擎发起 N+1 次调用,并且每一次调用都涉及完整的 GDI 往返以请求操作系统完成对文本的塑形与测量并返回结果

每一行听起来开销很低,但如果在一整页上看,代价就不菲了。想象一张排满文字的 A4 正文页,约 45 行,每行大概 80 个字符。以每行 N+1 次往返计算,这意味着每行约需要进行 81 次往返,而整个页面大概就是 3645 次。所有这一切几乎都耗费在重测引擎几秒前刚看过的文本上。在一个输出上千页的批处理任务中,这类开销成为排版时间的主要瓶颈,而每次往返又都要跨越进程与图形系统之间的边界

用一次调用代替 N 加一次调用

这次修正属于那种看似不起眼却回报巨大的改动。GDI 其实可以在单次查询中报告整个字符串的总宽度和每个字形的位置。HotPDF 将其以 GetWideCharAdvances 的形式暴露出来。它可以在一次调用而不是 N+1 次内将每一个字形的自然步进填入数组,还包含字距调整,然后返回总宽度。对齐例程(内部称为 _HPDFEmitJustifiedWideLine)请求一次所有步进、计算余白、将其分配至各处可伸展的边界上,然后发出该行

对于同一张 A4 页面,每行的测量开销从约 81 次往返降低到 1 次,整页的往返次数也从大约 3645 次锐减到约 45 次,这意味着约有 80 倍的降幅。输出内容字节对字节完全一样,因为除了测量次数之外,我们请求的测量信息没有变动。相同的 GDI 引擎,相同的字体参数,相同的字距调整提供了同样的数字。只是往返数量下降了而已。一旦测量是正确的,该被优化的就应该是不要再去反复询问,而不是去近似处理它

该行是如何到达页面的

在分配完余白之后,HotPDF 通过 ExtTextOut 输出这行文本,同时伴随一个每个字形的步进数组(即 Dx 数组)。每一个数组条目都代表着一个字形原点到下一个字形原点的距离;这是字形固有的自然步进加上被分配在该字形后方(如果有的话)的可伸展边界处的那份余白。它直接映射进 PDF 成像模型中。PDF 里被定好位的文本都是使用 TJ 操作符写进去的;该数组是将连续的字形块穿插了显式的水平方向修正;这正是这些修正项(Dx 的值)产生的地方。这就是为什么会有额外的空间准确地落在字形之间的小数点位置而不是通过填充假字符糊弄出来的,并且为什么哪怕你在后续使用其它工具重新读取这个经过对齐的 HotPDF 生成的文本时它的测量数据也丝毫无差的原因

在对齐段落时,你不必自己调用 ExtTextOut。程序入口是 WideTextOutBox。它会在将一个 Unicode 字符串按照你在接口调用的方式装入到一个文本框后,施以各种对其方式,然后再返回因受高度所限而最终能成功放置下的字符数目。在这个枚举(Justification enum)之中就可以定义你想用什么样的对其方式了

type
  THPDFJustificationType = (jtLeft, jtCenter, jtRight, jtJustify);

前三个很容易懂:左对齐、居中和右对齐。第四个 jtJustify 是本文讲述的两端对齐,这是被传递进 WideTextOutBox 用来启动感知不同文本脚本的对其方式切换的标志位

在实践中排版对齐一个段落

以下完整示例是先建立一个文档、选择字体并将其通过参数指定完全对其放入其中。同样一段程序不修改标记的属性也能同样在 CJK 以及拉丁文行里奏效,这一切都由 API 之下感知不同文本机制自动实现

uses
  HPDFDoc;

procedure JustifyParagraph;
var
  Pdf: THotPDF;
  Body: WideString;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'Justified.pdf';
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', 11);

    Body :=
      '两端对齐在每个被填满的行中分散余白,从而使两侧 ' +
      '边缘与列齐平,而最后一行保持其自然宽度。 ' +
      '对于有单词间隙的脚本,空格落在单词之间;而对于 ' +
      '没有它们的脚本,它在字形之间均匀分布。';

    // X, Y, LineSpacing, BoxWidth, BoxHeight, Text, Align
    Pdf.CurrentPage.WideTextOutBox(72, 72, 4, 380, 240, Body, jtJustify);

    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

如果要将相同的区块靠左排、居中,或靠右排列,只需要将传入此过程接口最后一个实参(argument)换为 jtLeftjtCenterjtRight。断行与包字逻辑、如何排放的算法和返回值都保持一致。四条路径所请求的测量宽度来自于 GetWideTextWidth(这是具有感知 Unicode 能力可以正常查询 WideString 宽度大小的方法);因为以往利用字节长去估计宽度,但凡文本超出了 Latin-1 将无法给出合适的测算大小;正是靠了 GetWideTextWidth 才能够正确的去自动在适当的地方分割好 surrogate-pair 以及 CJK 等字符的长宽

两端对其属于更大文本布局排印堆栈的一个分层而已。如果行包含可以使他们的字形变幻或被联合的字体,那这些空格测定将依赖这块的工作成果;在复杂脚本排字中我们对此有过详述的文章。如果要指定所排设字的异体字形,可参考如何去掌控 OpenType 的 GSUB 的样式调整法。这一套统统在为 Delphi 与 C++Builder 的 HotPDF Component 所附设功能,还辅佐有很多博客提到过的排印,格式和整套关于建立 Document 等各种相关的 API 在内等模块一起呈现