章 4 · 篇 II · 工艺
断词与两端对齐
Postext如何用Knuth-Plass算法断行、两端对齐文本并控制词间距
业余排版和专业排版的差别,就在词与词之间的空隙里。
随手翻开一本平装小说。正文两端对齐,每段的左右两边都整整齐齐。再凑近看:一行接一行,词间距几乎一致。页面上没有一道道纵贯而下的白色“河流”,也没有哪一行只剩两个词,中间隔着一大片空白。做到这一点远比看上去难,排版技术在这一个问题上花的工夫比在其他任何问题上都多。
Postext用的是TeX自1981年起一直使用的算法:Knuth-Plass最优断行。再加上TeX品质的断词模式、可配置的词间距上下限,以及一套用来找出问题行的可视化调试手段,引擎排出的两端对齐文本能够达到出版标准。
#问题所在
文本以textAlign: 'justify'排版时,除末行外,每一行都要拉伸或压缩到恰好等于栏宽。引擎把内容自然宽度与栏宽之差分摊到该行的各个词间空格上。(末行按自然宽度排,右边不齐;唯一的例外见末行。)
一行里词多,每个空格只需要微调一点,读者察觉不到。一行里词少(比如一个长词迫使提前断行),每个空格就得大幅拉伸,结果是松散行:词间距宽得打乱阅读节奏,留下难看的空隙。
反过来也有问题。引擎在一行里塞进太多词,空格就会缩到自然宽度以下,形成词挤在一起的紧缩行。
朴素的断行算法(CSS用的就是这种)一次只决定一行:在当前行里尽量多放词,断开,接着排下一行。这种贪心的首次适配方法有一个根本缺陷:它看不到后面。对第5行看似最优的决定,可能逼得第6行只能在很糟的地方断开。等算法排到第6行,已经来不及了,第5行早已定下。
#Knuth-Plass:通观整个段落
Knuth-Plass算法由Donald Knuth和Michael Plass于1981年发表,思路完全不同。它不是一次断一行,而是考虑整个段落所有可能的断行方式,选出使各行“劣度”总和最小的组合。TeX用的正是这个算法;四十多年来,TeX排出的文档一直是两端对齐文本的黄金标准,原因就在这里。
#盒子–粘连–惩罚值模型
Knuth-Plass不以词和空格来思考。它把文本建模为由三种基本元素组成的序列:
| 基本元素 | 代表 | 行为 |
|---|---|---|
| 盒子(Box) | 一个词或文本片段 | 宽度固定。不能拉伸或压缩,也不能断开。 |
| 粘连(Glue) | 词间空格 | 有自然宽度、伸展量和收缩量。引擎可以在这些范围内调整粘连来填满一行。 |
| 惩罚值(Penalty) | 一个可能的断点 | 带有代价。惩罚值低,断开的代价小;惩罚值高,代价大。带标记的惩罚值表示断词位置(选用时会画出一个连字符)。 |
一个段落会变成这样的序列:
[box "The"] [glue] [box "quick"] [glue] [box "brown"] [glue] [box "fox"]
[penalty -∞] ← forced break at paragraph end
启用断词后,长词会被拆成片段,片段之间用惩罚值隔开:
[box "ty"] [penalty 50, flagged] [box "pog"] [penalty 50, flagged] [box "raphy"]
每个带标记的惩罚值代价为50:不是免费的,但比排出一个松散行便宜。
#如何找到最优解
算法采用动态规划。它维护一组活动节点,即可以开始新一行的潜在断点,并从每个活动节点出发评估所有可行的断点。对每个候选断点,它计算:
-
伸缩比(r):这一行的粘连需要伸展或收缩多少。
r = 0表示这一行刚好排满;r > 0表示需要伸展(松);r < 0表示需要收缩(紧)。 -
劣度:衡量间距不均匀的程度,计算公式为
100 × |r|³。立方增长意味着稍松的行可以容忍,很松的行则会受到重罚。r = 2的行劣度为800;r = 0.5的行劣度为12。 -
适配等级:每一行归入紧(
r < -0.5)、正常(-0.5 ≤ r < 0.5)、松(0.5 ≤ r < 1.0)或很松(r ≥ 1.0)四类之一。紧行紧挨着很松的行,反差会很刺眼,所以相邻两行的等级相差超过一级时要受罚。 -
缺陷值:在此处断行的总代价。与TeX一样,劣度与断点惩罚值以平方公式组合:惩罚值非负时
demerits = (1 + badness + penalty)²;惩罚值为负(表示希望在此断开)时则减去它的平方:(1 + badness)² − penalty²。在此之上再加两项固定缺陷值:- 连续连字符缺陷值(默认3000):惩罚连续两行都以断词结尾,因为叠在一起的连字符会分散注意力。
- 适配等级缺陷值(默认100):本行适配等级与上一行相差超过一级时加上。
下文介绍的孤字惩罚值也进入这个公式,以额外劣度的形式注入;段首孤行和段末孤行惩罚值则在更晚的阶段起作用,即段落跨栏拆分时。
算法在整个段落范围内选出缺陷值总和最低的断点序列,这就是它胜过贪心算法的全部所在。
算法沿着活动节点回溯,找出缺陷值总和最低的路径,也就是整个段落全局最优的一组断点。
段首孤行、段末孤行和孤字惩罚值
Postext在标准代价模型之上加了三项编辑惩罚值,引导引擎避开段落末尾和栏末尾视觉效果欠佳的版面:
- 段末孤行(orphan)惩罚值:候选拆分会在下一栏顶部留下少于
orphanMinLines行时生效。orphanPenalty默认为1000。 - 段首孤行(widow)惩罚值:拆分会在当前栏底部留下少于
widowMinLines行时生效。widowPenalty默认为1000。 - 孤字惩罚值:段落末行短于约
runtMinCharacters × normalSpaceWidth像素时生效。这里数的是词间空格的宽度,不是字母:默认值20大约相当于8到12个字母的末行。runtPenalty默认为1000,对所有孤字都一样;启用gradedRuntPenalty后,它按该行的欠缺程度缩放,即runtPenalty × (1 − width / threshold),于是两词结尾比一词结尾代价低,在上面某行能让出一个词时胜出。段首孤行和段末孤行惩罚值是线性加到拆分缺陷值上的;孤字惩罚值则不同,它在Knuth–Plass平方公式内部以等效劣度注入,这样它与行劣度(上限为10000)在同一尺度上竞争,而不会被后者淹没。
如果惩罚值还是输了(其他断点全都不可行),引擎就用排字工人手工的办法:把段落排短一行(tightenRunts)。每一行的词间距都收紧,但不低于minWordSpacing;仅靠这一点还容不下落单的词时,再加一点负字距,取可行的最小一步,且不超过maxRuntTracking(单位为千分之一em)。排短后的版本同样要遵守maxWordSpacing。把这些词挤进少一行,多数行会收紧,但也可能挪动某个断点,使另一行不得不大幅拉伸。如果某种排法会让一行拉伸超过maxWordSpacing,或者在段落里已有更松的一行时超过那一行,那就不算修正,孤字保留。只有仍保持两端对齐的行才计入:因为拉伸会超过正常空格3倍而被段落改排为齐左的行(见断行算法填不满的行)不会抬高这条线,而且排短后的版本齐左的行不得多于原段落。这项修正也不会用超过这条线的两端对齐行去换掉一行齐左的行:段落保留它的齐左行和孤字。各栏齐底反过来遵循同样的规则:为填满一个偏短的栏而要把某个段落多排一行时,如果多出的那一行以孤字结尾,就不动这个段落。填满一栏不是让一个音节落单的理由。
孤字惩罚值在断行算法内部加到候选节点的缺陷值上,所以求解器可以用稍松的一行去换一个更长的末行,但只限于maxWordSpacing之内:拉伸超过这个上限的行会带上一份比任何孤字或连字符惩罚值都大的额外劣度,因此断行算法宁可接受较短的末行或断开的末词,也不让词间距越过上限。段首孤行和段末孤行惩罚值进入另一项优化,它在段落跨越栏边界时运行:对每个候选拆分打分(空余量、段首孤行和段末孤行缺陷值相加),代价最低的拆分胜出,因此只要可能,引擎自然会选择两种孤行都避开的拆分。调整orphanPenalty、widowPenalty或runtPenalty可以改变其间的取舍;把其中任何一个设为0即可完全关闭该规则。列表项通过avoidOrphansInLists、avoidWidowsInLists和avoidRuntsInLists启用这些规则(默认都为true)。
#为什么重要
实际差别肉眼可见。在贪心排出的版面里,你会发现某些段落有一行明显比相邻的行松;仔细看就会发现,原因是上一行多拿了一个词。Knuth-Plass能看到后果,所以会让当前行稍差一点,换来下一行好得多,从而避免这种情况。
#Postext的实现
Postext在核心包的knuthPlass/模块中完整实现了Knuth-Plass算法:一个动态规划核心(活动节点、缺陷值、回溯),加上两条把文本转换为盒子–粘连–惩罚值模型的适配路径:
- 纯文本路径:用
@chenglou/pretext做不依赖DOM的文本测量。Pretext提供片段宽度和自选连字符宽度,Postext把它们转换成KP元素。 - 富文本路径:用基于Canvas的测量处理粗体和斜体片段。每个带样式的记号变成一个或多个盒子,断词位置作为惩罚值插入。
两条路径都会为每一行计算justifiedSpaceRatio,即实际空格宽度除以自然空格宽度,供下文介绍的松散行调试功能使用。这个比值只对非末行计算;末行怎样排见末行。
断行算法之外,文本测量位于measure/模块:纯文本和富文本测量路径、Canvas字形度量、字体字符串处理,都在一个显式的测量缓存之后。clearMeasurementCache也会清空底层的文本宽度缓存,所以网络字体加载完成后测量结果依然准确。解析,即把源文本变成块和行内片段的阶段,位于parse/模块。这些模块的文件级结构见架构页面。
断行还与较新的几类内容相互影响。资源浮动体占据一栏或一页顶部或底部的一条区域,缩短栏的高度,而段首孤行、段末孤行和空余量惩罚值正是对栏高作出反应。题注和表格单元格用题注字体和表格字体,按换行的富文本片段测量。行间数学公式居中且不可分割,从不两端对齐,也从不断开。详见文档格式 › 资源以及配置 › 表格样式 / 题注样式。
回退行为:如果Knuth-Plass找不到有效的断点(栏极窄或词极长时可能出现),引擎会回退到Pretext的贪心layoutNextLine()。这样排版总能完成。
#断词
断词与两端对齐密不可分。没有断词,引擎避免松散行的唯一办法就是把一个词移到下一行,而这往往只是把问题挪了个地方。断词给引擎提供了多得多的候选断点,大大提高两端对齐文本的质量。
#TeX品质的断词模式
Postext用Hypher(hypher v0.2.5)断词,背后是TeX/Liang断词模式。这与TeX自1983年起使用的模式相同:Frank Liang的模式生成算法从大型词语语料中归纳出音节边界规则,并以紧凑的形式表示。
这些模式编码了一组带编号的规则,叠加到一个词上时,奇数表示允许断开,偶数表示禁止断开。每种语言模式文件中的leftmin和rightmin参数保证任何断点前后都至少有一定数量的字符。英语(en-us)通常分别为2和3,即连字符前至少2个字符,之后至少3个。
#支持的语言区域
| 语言区域代码 | 语言 |
|---|---|
'en-us' | 英语(美国) |
'es' | 西班牙语 |
'fr' | 法语 |
'de' | 德语 |
'it' | 意大利语 |
'pt' | 葡萄牙语 |
'ca' | 加泰罗尼亚语 |
'nl' | 荷兰语 |
每个语言区域加载各自的模式集。Hypher实例按需创建并缓存:某个语言区域的第一次调用承担初始化开销,之后的调用即刻返回。带地区标记的语言区域使用其语言的模式('es-ES'按'es'断词,'en-GB'按'en-us')。这里没有模式的语言回退到en-us,引擎会在控制台警告一次(见配置参考中的断词)。中文、日文和韩文例外:它们不断词,也不发出警告,行在字与字之间断开(见下文中文、日文和韩文)。
#为什么选Hypher(而不是自定义算法)
Postext最初的断词系统用的是一种基于元音的自定义启发式方法:通过查找元音组合、常见前缀(over-、under-、inter-)和常见后缀(-tion、-ment、-sion)来判断音节边界。它简单、快速,但有根本局限:
| 方面 | 自定义启发式方法 | Hypher(TeX模式) |
|---|---|---|
| 准确性 | 常见词表现不错,少见词不可靠。元音检测会漏掉许多有效断点,还会造出无效断点。 | 接近完美。模式从大型语料生成,并经过40多年的改进。 |
| 语言覆盖 | 每种语言都要手工定义元音集、前缀和后缀,繁琐且容易出错。 | 50多种语言都有模式文件,由TeX社区维护。增加一种语言只需多一条import。 |
| 行业标准 | 不是公认的标准,没有工具或社区支持。 | TeX、LibreOffice、Firefox、Chrome以及几乎所有专业排版系统都用同样的模式。 |
| 维护 | 每个边缘情况都是一个要手工修复的bug。 | 模式文件由社区维护,修复来自上游。 |
| 包体积 | 约170行,无依赖。 | Hypher核心约3 KB。每个语言模式文件增加20–80 KB(gzip后5–20 KB)。内置的全部8种语言区域合计约300 KB(gzip后约80 KB)。 |
| 性能 | 很快(简单的字符串扫描)。 | 快(每个字符一次trie查找)。实际开销可以忽略,断词从来不是瓶颈。 |
取舍很清楚:包体积大一些,换来正确性的大幅提升,而且不用费力维护。对一个以出版级输出为目标的排版引擎,正确性优先。印刷成书的一处错误断词,代价比多出几KB模式文件高得多。
#断词如何与Knuth-Plass结合
文本进入Knuth-Plass算法之前,引擎先用Hypher预处理,在每个合法断点插入软连字符(Unicode \u00AD)。这些不可见字符随后映射为KP的惩罚值,代价为50,flagged: true。
算法把断词断点当作又一个选项,与自然的词边界(粘连处允许断开)一起评估。如果使用连字符得到的缺陷值总和比让这一行松散更低,算法就采用连字符;否则保留整个词。
连续连字符缺陷值(默认3000)使算法尽力避免相邻两行都以连字符结尾,几乎所有体例规范都要求这样做。
一栏或一页最后一行的连字符会让读者在词的中间跳到下一栏。bodyText.hyphenateAcrossColumns: false让这些行不带连字符:结束一栏的那一行以连字符结尾时,段落会重新断行,把那里的连字符按孤字计价;只要上面各行的词间距能在maxWordSpacing和minWordSpacing之内吸收差额,断行算法就会让那一行以完整的词结尾。吸收不了时,连字符保留。它覆盖段落的第一个跨栏断点,以及之后落在整栏结束处的断点。之后落在别处的断点(为避开段首孤行而提前截短的栏、截平的收尾区域)会从那一栏开始再做一次重新断行:前面各栏已排好的行保留原断点,只有段落的其余部分重新断行。这样,只有在限度之内怎么排都避不开时连字符才会保留,实际上就是在段落的第一个跨栏断点处。断行算法只区分到它所守护的最后一行为止的各种到达方式,所以一次重试的代价与第一次测量相当:每重新断行一个段落,多做一次测量。
默认情况下,只有bodyText.hyphenation.enabled为true并且bodyText.textAlign为'justify'时才断词:不齐的一边本来就应当参差。齐左文本也可以断词,设置bodyText.hyphenation.ragged: true即可。齐左的行没有词间距需要匀开,因此由断词区来决定:放不下的词,只有在整个移到下一行会留下宽于hyphenation.zone(默认3 em)的空隙时才断开。齐左的正文也由Knuth-Plass排(bodyText.optimalRagged,默认开启):词间空格保持宽度,每一行按它比行长短多少计价,由断词区决定它可以取哪些音节;连续两行以音节结尾要计连续连字符缺陷值。逐行排时(optimalRagged: false),以音节结尾的行不会连续超过两行。见齐左文本。
有两种断点与这项设置无关。一是两个字母之间的硬连字符,如enseñanza-aprendizaje、físico-química,行可以在这里结束:词本来就带着连字符,所以不再添加。启用bodyText.breakAfterHyphens(默认开启)时,Knuth-Plass在每个段落中都会采用它,计价与音节相同;在不带行内格式的两端对齐段落中,只有连字符两边各有两个字母时才采用,所以不会有一行结束在e-mail的e-上。关闭它时(与postext 1.4及以前一样),不带行内格式的两端对齐段落不会紧接这种连字符断开,而同一段落里只要任意一个词是斜体就会断开;postext 1.5之前保存的书读入时这项设置为关闭。行结束在文本自带的连字符上,该行会在hyphenated之外记录hardHyphen,所以那些会去掉引擎所加连字符的地方(书眉、沙盒的光标)仍会保留这个连字符。二是两个词之间紧排的破折号或连接号,如say—that’s、riddles.—I、Hamburg–Berlin,启用bodyText.breakAfterDashes(默认开启)时,两种断行算法都可以在这里结束一行:行结束在破折号上,不添加任何东西,也不计任何代价。一个样式片段末尾的破折号后面紧跟另一样式的词(see—*and*)时也一样。此时该行记为hyphenated,不带hardHyphen。以下情况从不在破折号后断开:破折号引出插入语或一行对白时(—dijo、said "—Hola;结束一个词的引号可以位于破折号之前,如"no"—and或德语的„nein“—und),破折号后面是标点、引号或括号时(thinking—" and、says—“no”),在连续的破折号之中,或在数字范围之中(1914–1918)。此外,比整个行长还宽的词(窄表格单元格、窄栏中的长复合词)绝不会越出行长:引擎在放得下的最后一个音节处加连字符断开;词典在那里没有音节可断时,就在放得下的最后一个字符处断开;如果断点紧挨着词本身带的连字符,就断在那个连字符之后,不再添加,所以词里不会出现两个连字符。词的其余部分保留自身的断点:复合词继续在连字符处断开,网址在各个连接处断开;postext 1.4及以前,其余部分按词典的音节划分。不带行内格式的段落,除非启用了断词,否则在放得下的最后一个字符处截断,不加连字符。含有这种词时,Knuth-Plass找不到任何可行的断点组合,所以含有它的两端对齐段落改为逐行排。
#复合词
用连字符连接的复合词(after-dinner、teórico-práctico)可以在两类位置断开:自带的连字符之后,以及任一部分内部的词典音节处(af-ter-dinner)。TeX和《芝加哥格式手册》都让各部分保持完整,只在连字符处断开复合词。bodyText.hyphenation.compounds: false也这样做:词典不处理任何在两个字母之间带连字符的词,所以它只在那个连字符之后断开。词里手动输入的软连字符照样可以断开,比整行还宽的复合词在必要时也照样会被划分。默认值true继续划分各部分,让两端对齐的窄栏有更多办法填满各行。
葡萄牙语正字法要求,复合词在行末断开时,连字符要在下一行开头重复(vencer- · -se);西班牙皇家学院自2010年起的规则也这样要求(léxico- · -semántico),好让读者知道连字符属于这个词。bodyText.repeatHyphen: true实现这一点。重复的连字符与所在行一起测量,所以断行算法会为它留出位置,它也与其余文本一样绘制;在PDF中,它带有一段略去它的替代文本,所以从PDF复制或提取的文本中这个词只出现一次(vencer-se)。该行把它记录为repeatedHyphen,其plainStart和sourceStart指向它之后,所以源映射、链接和书眉读到的是这个词的原样写法。网址从不重复连字符。它适用于正文、标题、列表、引文块和标注框;含有复合词的无格式段落此时改由排带格式文本的断行算法断行。
#行从不断开的地方
有些位置在两种断行算法中都不能断开:
- 不换行空格。 U+00A0、窄不换行空格U+202F和数字空格U+2007把两边的词粘在一起:数字和单位、页码引用、千位分组。题注、表格单元格、标注框和版面设计文本(书眉、章首页)中也一样。每种空格都保留测量得到的自身宽度;两端对齐只拉伸词间空格。字体中没有U+202F或U+2007字形时,所有输出(包括PDF)都采用浏览器给它们的宽度,分别为半个词间空格和一个数字宽;几乎每种字体都有U+00A0。用这种方式粘在一起的一组文字如果比整行还宽,则是例外,无论有无行内格式:它在最后一个不换行空格处断开,而不是在某个词内部截断。词连接符U+2060只粘连、不占位置,在中文文本中也一样:义项编号
**①**后面跟一个词连接符,就会与其后的字留在同一行。U+FEFF(零宽不换行空格)也是如此,不过专门用于此的字符是词连接符。 - 紧贴的文本。 粗体或斜体词及其后的标点(
**osmosis**.)、行内引用两侧的括号((:ref{id="fig-3"}))、分成两个片段排的一个词:中间没有空格的文本是一个单位,如同一个词。这样的单位放不进一行剩下的空间时,整体移到下一行。它位于行首仍放不下时,其最后一个词在某个音节处断开,让标点随词尾一起下移;只有无音节可断的单位才会让标点出现在下一行开头。
postext 1.4及以前,带行内格式或:ref的段落可能在不换行空格处断开,齐左的行也可能结束在引用前的(上,或以粗体词后的句号开头。
#词间距上下限
粘连模型为引擎明确规定了词间空格可以伸缩的范围。这一范围由bodyText上的两个配置属性控制:
| 属性 | 默认值 | 说明 |
|---|---|---|
maxWordSpacing | 2 | 词间距上限,以正常空格宽度的倍数表示。取默认值时,空格最多可以伸展到自然宽度的200%。 |
minWordSpacing | 0.6 | 词间距下限,以正常空格宽度的倍数表示。取默认值时,空格最多可以收缩到自然宽度的60%。 |
这两个倍数直接换算为Knuth-Plass模型中粘连的stretch和shrink值:
stretchPerSpace = normalSpaceWidth × (maxWordSpacing - 1)
shrinkPerSpace = normalSpaceWidth × (1 - minWordSpacing)
取默认值(2 / 0.6)时,如果正常空格宽度为4 px:
- 每个空格可以伸展4 px(从4 px到8 px)
- 每个空格可以收缩1.6 px(从4 px到2.4 px)
更窄的范围(如maxWordSpacing: 1.2)使间距更均匀,但给算法留的回旋余地更小,可能导致更多断词,极端情况下还会溢出。更宽的范围(如maxWordSpacing: 2.5)给算法更大的灵活性,但有些行的间距会明显不匀。
默认值2和0.6偏向算法的灵活性:让Knuth-Plass在窄栏中有足够余地避免溢出、断词和孤字,同时仍稳稳处在排版文献认为可以接受的范围内。
#断行算法填不满的行
Knuth-Plass在有选择时不会把一行拉伸到超过maxWordSpacing,孤字修正也不会(见段首孤行、段末孤行和孤字惩罚值)。一行一旦超过上限,它的代价就比算法权衡的其他任何缺陷都高,无论是一个连字符、一个短末行,还是比邻行稍松的一行;此后代价还随伸缩比的平方增长。所以断行算法宁可断开一个词、把空余量分摊到周围各行,或者让两行都稍松,也不让一行松得太多。
有时没有任何断点组合能留在范围之内。放不下下一个词的那一行,空格太少,分摊不了空余量:段首缩进下的第一行,后面跟着一个太长而上不来、又太短而无法断词的词;一行由不可断开的长术语组成;一个只能在自身连接处断开的长网址;一个列表项,它的最后一个词上不来,而任何断词都会留下孤字。这种情况出现得多频繁,取决于行长、字体和语言。在一个83 mm宽、9.3 pt英文的栏里,一章中有2%到8%的行超过了maxWordSpacing,因字体而异(字面最宽的最多);同一章的西班牙语版本断词机会多得多,一行也没有。这时Knuth-Plass接受最不坏的一行,它的空格拉伸超过上限。当空格会拉伸到正常空格宽度的3倍以上时,引擎改为把这一行排成齐左:按自然间距绘制,右边不齐,就像段落的末行。这个阈值与沙盒的松散行警告所用的阈值相同,所以超过它的行会被修正,而不是被标记。在阈值之内的行按测量结果保留。标注框内部也是如此,它们的行长窄,这种情况最常见;postext 1.4及以前,标注框无论空格拉伸多宽都让这样的行保持两端对齐。
要减少这种行,按以下顺序尝试:检查断词是否开启并设为文本的语言;加大行长或减小字号;让这些行带一点字距(见下一节);或者提高maxWordSpacing,这会让同样的行算作在上限之内,但排法不变。
#最后手段:字距
bodyText.maxJustifyTracking让这样的行带一点字距(字母间距),而不是再加宽词间空格,每个字符最多为这么多千分之一em(10 = 0.01 em,即InDesign的单位)。它双向起作用:空格会拉伸超过maxWordSpacing的行把字母拉开,直到空格回到上限;只有把空格压到minWordSpacing以下才放得下的行则收紧字母,而不是把一个词挪到下一行。Knuth-Plass在选择断点时就把它考虑进去:带字距的行比在范围之内的行代价略高,比超出范围的行代价低得多,所以只在单靠词间距行不通的地方才用字距。在范围之内的行、段落的末行(除非它超出行长)、只有一个词的行以及含有行内标签的行都不带字距。该行把它记录为VDTLine.letterSpacing,叠加在所在块自身的字距之上,Canvas、HTML和PDF都会绘制它。
bodyText: {
maxWordSpacing: 2,
maxJustifyTracking: 10, // 每个字母最多0.01 em,收紧或拉开均可
}它默认关闭,文档不主动开启,各行就保持原样。在上文的英文章节中,10‰把超过maxWordSpacing的行减少到每种字体一行或零行,20‰则让四种字体中的三种降为零行。数值宜小:超过20‰左右,字距就会显现为一行颜色偏浅或偏深。
#末行
在盒子–粘连–惩罚值模型中,每个段落都以一个宽度为0、可无限伸展的粘连加一个强制断点结束。这个末尾粘连以零代价吸收末行剩下的所有空间,所以末行自然是右边不齐的:它们按自然宽度渲染,justifiedSpaceRatio也只对非末行计算。
有一个例外。Knuth-Plass可能接受一个自然内容宽于行长的末行,前提是它的词间粘连会收缩,这是TeX粘连设置的标准语义。三个后端都会检测这种情况(该行内容的自然宽度超过有效行长),并压缩末行的词间空格,使它恰好等于行长,而不是溢出。Canvas、PDF和HTML后端采用完全相同的检查。
#最优断行与贪心断行
bodyText.optimalLineBreaking属性(默认:true)控制引擎使用哪种断行算法:
true:Knuth-Plass动态规划算法。评估所有可能的断点组合,选出全局最优的一组。所有两端对齐文本都推荐这项设置。启用bodyText.optimalRagged(默认开启)时,齐左的正文也用它:词间空格保持宽度,断行算法改为权衡每一行比行长短多少,短3 em的一行与词间距达到maxWordSpacing的两端对齐行代价相当,因此不齐的一边更均匀,孤字规则也适用。false:贪心首次适配,由Pretext的layoutNextLine()实现。更快,但效果较差。只在性能比排版质量更重要时使用(例如字符数极多时的实时预览)。
Knuth-Plass启用却找不到有效断点时(栏极窄或词比栏还宽时可能出现),引擎会对该段落自动回退到贪心断行。
#中文、日文和韩文
CJK字符多于词间空格的段落由CJK排版器排,不用Knuth-Plass。两个字之间的每个间隙都可以断行(cjk.lineBreak的避头尾规则禁止之处除外),所以没有什么需要最优搜索去权衡;各行依次填满。一个字放不下时,这一行先尝试通过让出标点的空白和空格把它容纳进来(挤进:词间空格最少压到四分之一em,然后是间隔号、括号、顿号,中西文间距最少压到八分之一em,再是句末点号,以cjk.punctuationWidth允许的程度为限);只有这样还不够时,不能出现在行首的字才把前一个字一起带到下一行。规则见东亚排版。中文版式介绍中文排版的其余部分:各地区的标点宽度、字符网格和竖排。
这种段落中两端对齐的行(末行除外),按clreq规定的顺序(§6.2.2.4)匀开到行长:
- 西文词之间的空格,平均分配,每个最多半个em。
- 中西文之间的空格(
cjk.latinSpacing),平均分配,每个最多半个em。 - 字与字之间的每个间隙,平均分配,中西文间距也包括在内:汉字之间、汉字与标点之间、汉字与西文词之间。从不在西文词、数字、两字长的破折号或省略号内部,也从不在连接号(~、–、单个—)或斜线旁边。悬挂到行末之外的标点(
cjk.hangingPunctuation)不计入行长。
段落末行按原样排,不加间距。间距按行的片段记录(VDTLineSegment.tracking),所以Canvas、HTML和PDF绘制的位置相同,沙盒也能把光标放在点击的那个字上。
一行需要在字与字之间加超过半个em(设置了bodyText.maxJustifyTracking时,则是超过该值)才能排满时,就按这个上限排,不到行长,并报告一条cjkLooseLine内容警告(在沙盒中为CJK行排不满)。常见原因是一个长西文词或网址无法上到这一行。行中没有CJK字符时,比如长网址的开头部分,就排成齐左,不发出警告。
在松散行高亮中,CJK行按其间距判断是否松散:比值为1加上以八分之一em为单位的间距,所以在默认阈值3下,字间距超过四分之一em的行会被标出(lineLooseness(line, fontSizePx)给出这个比值)。各栏齐底可以把CJK段落多排一行:它把各行断得比行长略短,每次短八分之一em,最多两个em,再把它们匀开到行长。
#松散行调试
即使有Knuth-Plass和断词,有些行仍会比理想状态松,尤其是在含长词的窄栏中,或断词机会少的语言中。调试功能松散行高亮帮你立刻找到这些问题行。
#工作原理
虚拟文档树中的每一行都带有justifiedSpaceRatio,即两端对齐后的实际空格宽度与字体自然空格宽度之比。值为1.0表示空格为自然宽度;值为2.5表示空格是正常宽度的2.5倍。
debug.looseLineHighlight.enabled为true时,沙盒在其Canvas预览上为每个justifiedSpaceRatio超过所设threshold的行绘制一层半透明覆盖层。默认阈值为3.0,即只高亮空格比正常宽三倍的行。这条线有意定得很高;松到这个程度的行是真正的排版问题。这也是引擎不再拉伸、而把两端对齐的行改排为齐左的宽度(见断行算法填不满的行),所以取默认值时,高亮和looseLines警告在正文里几乎找不到什么:这样的行已被修正,而不是被标记。把阈值降到1.5或2,可以找出松散但仍两端对齐的行。
覆盖层不属于页面,所以导出的页面上从不出现。要在你自己的Canvas上绘制它,在renderPageToCanvas之后紧接着调用drawLooseLines(ctx, page, doc, { threshold });findLooseLines(doc, { threshold })以数据形式返回同样的行(见在自己的Canvas中显示松散行)。
#配置
松散行高亮属于PostextConfig的debug部分:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
looseLineHighlight.enabled | boolean | false | 是否高亮松散行。 |
looseLineHighlight.color | ColorValue | #ff000040 | 高亮覆盖层的颜色。默认为半透明红色。 |
looseLineHighlight.threshold | number | 3 | 正常空格宽度的倍数,超过它的行视为松散。值越小,找出的行越多;值越大,只高亮最严重的行。取3或以上时,正文里几乎什么也不显示,因为超过3倍的行已排成齐左。 |
debug: {
looseLineHighlight: {
enabled: true,
threshold: 2.5,
color: { hex: '#ff660040', model: 'hex' },
},
}#解读结果
启用松散行高亮后,如果某些行上出现红色色带,说明引擎找不到办法在不过度加宽词间距的情况下排这些行。从上往下排查:原因按可能性从高到低排列。
| 原因 | 解决办法 |
|---|---|
| 栏宽相对字号太窄 | 加大栏宽、减小字号,或改为单栏版面。 |
| 长词的断词点少 | 确认断词已启用且语言区域设置正确。有些技术术语或专有名词没有有效断点。 |
| 断词被关闭 | 启用bodyText.hyphenation.enabled。不断词的两端对齐几乎总是更差。 |
| 无论换什么字体,总有几行是松的 | 让这些行带一点字距:bodyText.maxJustifyTracking: 10。见最后手段:字距。 |
| 词间距范围太窄 | 稍微提高maxWordSpacing(例如从2提高到2.4),给算法更多余地。 |
| 语言有很长的复合词(如德语) | 确保语言区域设置正确。德语断词模式能很好地处理复合词,但前提是引擎知道文本是德语。 |
#纵向两端对齐:各栏齐底
Knuth-Plass解决的是横向问题:每一行在哪里断开。各栏齐底解决的是与之对应的纵向问题:每一栏在哪里结束。出版者要求一页中的每一栏都从页顶开始、与页面底部齐平,整个跨页逐行对齐。而保护文本质量的那些规则恰恰与此相悖:段首孤行与段末孤行保护、keepWithNext标题、不可拆分的图,都会在当前栏还没排满时就把内容推到下一栏,在栏底留下一条或多条空的基线网格行。
启用headings.balancing时(默认启用),引擎会像排字工人那样补上这些空隙,按严格的编辑优先级依次使用各种手段;每种手段只处理前一种没能吸收的部分:
-
结束该栏的方框。 结束一个偏短栏的标注框按其底部下方的空余整体下移,使底边落在最后一条网格线上,与旁边的栏齐平;这段空余可以不足一行。这一手段最先执行,并占用全部空隙,除非
closingBox另有设定:'last'让下面几种手段先按整行占用,方框只取它们剩下的部分,这样为上方段落作注的方框仍能紧挨着它;'off'则从不移动方框。 -
标题上方的间距。 给偏短栏中各标题的上外边距增加整条基线网格行。需要增加多行且该栏有多个标题时,这些行按标题的重要程度轮流分配,因此最重要的标题总是分得最多:分给一个
h2和一个h3的3行,变成h2上方+2行、h3上方+1行。位于栏顶的标题不会得到额外间距(各栏仍从页顶开始),栏末的标题也不会被推向栏底。 -
列表结束后的间距。 在列表或编号列表结束处增加一条网格行,列表后留出空白读起来很自然。每个列表末尾有上限(
maxLinesAfterList,默认为1)。其次,在行间公式或正文从其后接续的方框下方增加一条网格行,在位于栏首的图或表下方也增加一条(stretchAfterFloats、maxLinesAfterFloat)。图下的文字可以是上一页起头的段落的剩余部分;它照样下移,下移的正是断行规则在栏底空出的那一行(段首孤行(widow)规则留空的一行,或者后面放不下文字的段间距)。 -
放松段落。 作为最后手段,把该栏中的段落重新断行,让它多出一行,即TeX的
\looseness=+1;最多处理maxLooseParagraphs个段落(默认两个),每段多一行。断行器重新运行Knuth-Plass,要求恰好多出一行,并且只有当放松后的每一行都保持在maxWordSpacing以下时才接受结果:文字灰度绝不会超过你已经设定的上限。引擎优先选择该栏中最长的段落,多出的空白分摊到最多的词间空隙里,就看不出来了。此手段需要optimalLineBreaking(默认启用);跨栏拆开的段落不参与。被孤字修正缩短了一行的段落,从修正后的状态开始计算多出的那一行:它可以把修正拿掉的那一行要回来,但只能采用既没有孤字、也没有任何一行超过maxWordSpacing的断行方案(postext 1.4及之前,它可能带着一行远超上限的词间距回来)。仅靠词间距无法多出一行时,段落还可以采用少量正字距,这是排字工人的经典做法。引擎先试最小的量(先是
maxTracking的一半,再是maxTracking,单位为每字符千分之一em;默认为10,即0.01 em),保留第一个能多出一行的量,仍然受同样的词间距限制。字距在测量段落各行时计入,并由每种后端绘制(Canvas的letterSpacing、CSS的letter-spacing、PDF的字符间距)。设置trackParagraphs: false可将其关闭。
#为什么修正只影响局部
分栏的断点由元素决定:开启下一栏的元素之所以在那里,是因为它放不进空隙。因此,把一栏的尾部最多下移其自身空隙的高度,永远不会把内容挤进下一栏或下一页;每一栏都固定在原位,不会引发连锁的重排。引擎仍然会实际验证这一点:它用拟定的调整重新执行放置(最多8遍),测量剩余空隙的总量,并始终保留找到的最佳版面。放松后的段落如果无论用多少字距都无法在词间距上限内多出一行,就被列入黑名单,引擎转而尝试下一个候选段落。
#什么时候不处理偏短的栏
偏短的栏有时才是正确的输出,齐底处理知道何时让开:
- 一页的最后一栏只有在该页自然流入下一页时才做齐底。由
:::pagebreak、标题的breakBefore或章首页结束的页面,保留偏短的最后一栏:一章本来就可以在页面中间结束。 - 文档的最后一页从不做齐底。
- 没有可用伸展点的栏(没有符合条件的标题、列表末尾或可放松的段落)保留空隙,而不去降低排版质量。
const doc = buildDocument(content, {
headings: {
balancing: {
enabled: true, // 默认
maxLinesPerHeading: 4, // 每个标题的上限
stretchAfterLists: true, // 手段3
maxLinesAfterList: 1, // 每个列表末尾的上限
looseParagraphs: true, // 手段4,受bodyText.maxWordSpacing约束
maxLooseParagraphs: 2, // 每个偏短栏中放松的段落数
trackParagraphs: true, // 允许放松的段落采用少量字距
maxTracking: 10, // 每字符‰ em(0.01 em)
closingBox: 'first', // 手段1:'first' | 'last' | 'off'
},
},
});在沙盒中,这些选项位于版面设计 → 标题与目录的标题部分(“各栏齐底”,其下嵌套“列表后加行”“图下加行”“栏末方框”和“放松段落”)。打开基线网格(版面设计 → 高级 → 屏幕辅助),在Canvas视图中查看效果:关闭齐底时,偏短的栏结束在最后一条网格线上方;打开后,每个可以齐底的栏都结束在同一条线上。完整的选项说明见配置页面。
#完整示例
一份展示全部断词与两端对齐设置的完整配置:
import { buildDocument } from 'postext';
const vdt = buildDocument(content, {
bodyText: {
fontFamily: 'EB Garamond',
fontSize: { value: 9, unit: 'pt' },
textAlign: 'justify',
// Knuth-Plass最优断行(默认:true)
optimalLineBreaking: true,
// 断词
hyphenation: {
enabled: true,
locale: 'es',
},
// 词间距范围(正常空格宽度的倍数)
maxWordSpacing: 2, // 空格最多拉伸到200%
minWordSpacing: 0.6, // 空格最多压缩到60%
},
// 调试:高亮词间距过大的行
debug: {
looseLineHighlight: {
enabled: true,
threshold: 2.5,
color: { hex: '#ff000040', model: 'hex' },
},
},
});