跳到主要内容

章 2.1 · 篇 I · 基础

架构:排版流水线

放置遍及其轮次:引擎怎样放置行、浮动体、注释和栏,并反复运行直到版面稳定

更新于 2026-10-1619分钟所属章节: 2 架构enescadefritptzhjaar

简单来说

本页写给想了解Postext怎样在页面上摆放内容的程序员。它从头到尾走一遍文字,把每一行、每张图、每个表格、每条注释和每个框放到各自的位置。如果后面的步骤改动了前面的内容,比如引用里的一个页码,它就从头再来一遍。等到没有任何东西再移动,它才停下。本页列出每一步、它决定什么,以及它最多能运行几次。

#排版流水线

一个放置遍,版面需要几次就运行几次。这就是排版流水线的全部。

这一遍(placeDocumentPass,位于packages/postext/src/pipeline/build.ts)从第一个块到最后一个块遍历内容。对每个块,它做完排定这个块所需的一切:按块即将进入的那一栏的宽度测量它,看它是否放得下,按分页规则和保持同页规则拆分它或把它后移,给它引用的浮动体提供位置,再让文本流回到基线网格上(见垂直韵律系统)。浮动体、孤行和网格都没有各自单独的一遍:它们在每个块落定时就已解决。

有些决定无法在一次遍历之内做出,因为它们取决于遍历最终的结果:目录印出的页码,通栏框切割上方各栏的高度,一个短栏还缺的行数。对于这些决定,buildDocument带着从前一遍结果中取得的提示重新运行这一遍。下面的五个阶段跟随一个块走完这一遍;放置遍的驱动器和各栏齐底讲述重新运行。

#第1阶段:内容结构化

  • 输入:原始的PostextContent
  • 处理:把Markdown解析成内容块,按ID在resources[]中解析资源引用,把脚注定义([^id]:)移出文本流并按引用顺序给标记编号,给标题编号,并规划浮动体:哪些资源浮动,排在哪个块之后(不读取notes[])
  • 输出:内容块的扁平列表(已确定类型并填入内容,但尚未分配页和栏)和浮动体规划
  • 运行时机:每个放置遍开始时。第一遍之后,解析结果从记忆化缓存中读出;其余部分重新计算

#第2阶段:文本测量

  • 输入:一个含文本的内容块,以及它即将进入的那一栏的宽度
  • 处理:通过专门的测量模块(packages/postext/src/measure/)按这个宽度测量块的各行。该模块在Pretext之上叠加了断词、两端对齐、富文本行内片段和Knuth-Plass断行。结果是块的VDTLine[]和总高度
  • 关键细节:测量结果有缓存。cachedMeasureBlock / cachedMeasureRichBlock(位于measure/cache.ts)以文本、字体、宽度和所有影响版面的选项为键,重新测量一个未改动的段落只是一次映射查找
  • 输出:块的各行,带有精确的像素尺寸
  • 运行时机:在遍历到达这个块时,而不是提前进行,因为宽度取决于块落在哪里:侧栏旁边的栏,绕排图旁边的那些行,:::columns组的子栏。块转到另一种宽度的栏时,按那个宽度重新测量

该模块按职责清晰拆分:plain.ts测量纯文本片段,rich.ts测量粗体、斜体、数学公式混排的片段,font.ts构造字体字符串并管理缓存的生命周期,canvas.ts封装底层的Canvas文本宽度原语。有一个生命周期细节在实践中很重要:按后备字体测得的字形宽度,不能在真实字体到达之后继续留用。canvas.ts按字体字符串保存宽度,并记录一个测量代次;evictFontFamilies(构建发现某个字体族的字体有变化时调用,watchFonts、prepareFonts和loadBundleFonts也会调用)丢弃这些字体族的宽度并清空Pretext的缓存,在较早代次下填充的MeasurementCache则在下一次查找时丢弃用这些字体族排出的块。clearMeasurementCache()对所有字体族做同样的事。字体本身由fonts/prepare.ts(prepareFonts)在排版前加载,fonts/faces.ts读出已排好的文档用到了哪些字体,用于fontFallback警告。

中文、日文和韩文段落在同一模块中走另一条路径。CJK字符数多于词间空格数的段落交给CJK排版器(cjkCompose.ts)处理。它把文本切分成单元(一个字符、一串拉丁文、一个两字宽的破折号或省略号、行内标签或注释标记这样不可分割的盒子),每个单元只测量一次,然后按cjkClasses.ts的避头尾规则以首次适配的方式填充各行:遇到不能出现在下一行行首的标点时,本行先通过标点挤压(cjkPunctuation.ts)让出标点的空白把它容纳进来,然后才考虑把一个字符移到下一行。两端对齐的行再把余量分配到字符之间。输出是普通的VDTLine,其片段携带渲染器按测量结果精确绘制所需的一切:tracking(每个字符后的像素数,已计入片段宽度)、让出空白的标点的inkOffset、hangs、作为autospace片段的汉字与拉丁文之间的间距,以及中文注释的着重号、注音和割注行。在日文文档中,同一个排版器遵循JLReq:断行时从每个单元两端的字符读出日文级别的禁则类别,标点空白按JLReq的顺序挤压和让出,振假名由rubyJis.ts排布(1:2:1间距、伸到假名上、熟语),汉文训读记号则加宽其所属字符的单元。开销与段落长度成线性关系:排《紅樓夢》第1回(6,949个字符)时测量了1,298个字符,每个不同的字符只测一次;而如果测量段落的各个前缀,则需要测量636,948个字符。详见中文排版。

在底层,这正是Pretext发挥作用的地方。prepare()调用开销大:它用Canvas字体引擎分析文本并缓存结果。而layout()调用只是纯算术,几乎没有开销。这种拆分是关键所在。文本一旦准备好,引擎再次给它断行的开销就可以忽略不计:换一个宽度,用于在更窄的栏中接续的段落;或者多排一行,用于被各栏齐底放松的段落。准备一次,需要排多少次就排多少次。

// 简化示例:测量模块在内部如何使用pretext
const prepared = prepare(paragraphText, '16px/1.5 Inter');
const { height } = layout(prepared, columnWidth, 24); // 行距24px
// => “在320px栏宽下,这个段落高168px,即7行。”

#第3阶段:页面与栏的放置

  • 输入:一个已测量的块
  • 处理:把块排入当前栏。随文本流的需要创建VDTPage和VDTColumn节点。记录每栏的availableHeight。某个块放不下时,拆分它(第5阶段),或者转到下一栏或下一页
  • 策略:贪心的首次适配放置。分栏和分页采用最简单的有效分配
  • 输出:这个块分配了pageIndex、columnIndex和bbox

到这一步,VDT才成为一份真正的文档。在这个阶段之前,块只是扁平列表中的一项,没有位置。放置把每个块分配到某一页的某一栏,就像往一排格子容器里倒水:先注满第1栏,溢出后流入第2栏,一页满了就开新页。

在往页面上放入任何内容块之前,这一遍先为结构性元素预留空间:书眉和页脚(按config.header / config.footer作为设计槽排出),以及已经等待放到新开页面上的浮动带。这些预留会减少每栏的availableHeight,因此内容块开始排入时,引擎已经确切知道还有多少空间。

栏带与通栏栏。page.columns是一个按阅读顺序排列的扁平数组,但一页并不总是只有一排栏。在多栏版面中,通栏的行内块会把页面切成上下堆叠的栏带。有四种东西算作这样的块:样式或围栏设置了span: 'page'的:::callout,正文中的:::columns{span="page"}组,放置规则解析为{ position: 'here', span: 'page' }的行内::resource,以及在书写处横跨页面排出的漫画条(:::strip{span=page})。后三种在放置之前先被包进一个无边框的通栏框(pipeline/flowColumns.ts、pipeline/spanEmbeds.ts),所以四种都由同一个机制排出。在切割处,当前栏带的文本栏在切割线处封闭(高度被截断,availableHeight为零),该块获得自己的一个全宽栏,kind: 'span',并在它下方追加一个新的文本栏带(band + 1,x和宽度相同,底边与被替代的栏带相同)。栏只会被追加,因此columnIndex始终对应page.columns[i],渲染器也无需特殊绘制:每栏按自己的bbox裁剪(由columnClipRect放宽:字形墨迹留2pt,再加上该栏的设计覆盖层(如标题标签或标注框徽标)超出其两侧的距离,以及标题设计超出其顶部的距离;底边仍是栏的边缘;Canvas和PDF后端使用同一个矩形)。栏间分隔线按栏带在相邻文本栏之间绘制,从页首通栏标题所占栏带的下方开始(columnRuleSegments);带样式的章节设置了分隔线时,使用该页自己的分隔线(VDTPage.columnRule,通过pageColumnRule读取)。各栏齐底会忽略通栏栏、侧栏和高度为零的栏带。通栏块在栏带齐平的位置(页首、紧接章首标题之后、另一个通栏块之后或顶部浮动带之后)直接切割;如果到达时栏带各栏不齐,它就提出一个栏带上限(packages/postext/src/pipeline/bandCaps.ts):以某个内容块开头的栏带,其各栏被缩短到ceil(Σ used / N / grid)行。buildDocument带着这个上限重新运行放置遍(受限栏带溢出时每次增加一行,最多MAX_BAND_CAP_RETRIES = 3次,之后放弃这个上限,该块于是退回到下一页的顶部),于是文本在遵守所有放置规则的前提下填满缩短后的各栏,在切割处齐平结束,封闭的栏把剩余的空白保留为availableHeight,交给各栏齐底去吸收。同一机制也用于让章节末尾和文档末尾的收尾栏带齐平(headings.balancing.trailing):遇到章首页、:::part、结束一章的placement: 'fixed'标注框或文档末尾时,如果当前栏带各栏不齐,就提出一个kind: 'trailing'上限,以该边界块为键。由于上限以开启其栏带的块为键,而只要前面某页吸收了额外的齐底行,这个块就会移动,因此收尾上限要在各栏齐底稳定之后、在冻结齐底提示的情况下求解,然后再进行一轮简短的润色,让各种调节手段补足切割留下的空缺。placement: 'fixed'的标注框脱离文本流:框锚定在页面内容区、成品框或出血框上,它覆盖的文本栏让出该区域(像浮动带一样从底部或顶部切掉,冲突时移到下一页),框体及其子元素进入page.floats。

竖排页面。设置layout.writingMode: 'vertical-rl'时,这一遍排出的是一个顺时针旋转了90度的横排页面。在这样的页面上,内容区、栏、块、行、浮动体和脚注区都使用流坐标,VDTPage.flow携带把它们变换到纸面上的旋转:流坐标中的点(x, y)落在(页宽 − y, x)。下游无需知道这一点:断行、浮动体、保持同页规则和各栏齐底都在流坐标系中照常工作,流中的一栏就是纸面上的一层,各后端在绘制时应用旋转(flowToPage和pageToFlow在两个方向上映射点)。书眉、页码、裁切标记和背景保持纸面坐标。图和表作为直立的块排版,在框内转回原方向;需要直立的字符在绘制时逐个转回。

#第4阶段:资源放置

  • 输入:第1阶段的浮动体规划,以及到目前为止已放置的各页
  • 处理:把每个被引用的资源浮动到其首次引用之后的第一个空闲位置:引用所在栏的底部、下一个空栏的顶部或底部,或下一页的某个浮动带(packages/postext/src/pipeline/floatPlacement.ts规划浮动体,pipeline/floatSlots.ts枚举并测量各位置;放置遍在遍历过程中预留浮动带)
  • 运行时机:包含首次引用的块一落定就运行,在同一次遍历之内;此后放置的文本绕开浮动带排布
  • 放置规则解析:对每个资源,引擎依次解析resource.placement → 该类型的resourceType.defaultPlacement → 内置默认值{ position: 'auto', span: 'column' }
放置字段行为
position: 'auto'资源占据引用之后的第一个空闲位置,顶部或底部均可,这是默认值
position: 'top'只用顶部位置:下一个空栏或下一页顶部的浮动带,把栏内容推到它下方
position: 'bottom'只用底部位置:栏底或页底的浮动带,缩短其上方的栏
position: 'here'不浮动:资源在其::resource指令处以行内方式嵌入,就在它出现在文本流中的位置
span: 'column'浮动带只占一栏(在新开的页面上,引擎选择剩余空间最多的那一栏)
span: 'page'浮动带横跨所有栏,占满整个内容宽度,打断栏的文本流;全宽浮动带最先预留,因此单栏浮动体嵌套在剩余空间中
span: 'side'资源排在一栏半版式的侧栏中,前提是该版式把侧栏留给浮动体(layout.sideColumnRole: 'floats');资源叠放在首次引用它的段落旁边。在没有这种侧栏的页面上,它的行为与'column'相同

除了这两个字段,ResourcePlacement还有rotate、width、align、columns、captionSide、shrink、minScale、captionMeasure、wrap、wrapGap和citingPage。各字段的作用见配置 → 资源类型和文档格式 › 位置。

  • 延后放置:在当前页找不到任何合适位置的浮动体会等到文本流打开的下一页。在章节边界处,它会被排到边界之前新开的页面上。图片保持原尺寸,除非placement.shrink(或作用于整个文档的layout.floatShrink)允许把它缩小到某个位置实有的空间。新页面也放不下的表格按行切开,在后续页面上接续,并重复表头行(tableStyle.overflow: 'split',默认值)
  • 输出:资源定位在页面浮动带中(page.floats),受影响的栏高度相应减少,使文本绕开浮动带排布

资源放置是有意思的地方,因为浮动体不只是占据空间,还会重塑周围的空间。包含引用的块落定后,等待中的浮动体会依次尝试当前页该块之后的空闲位置:先是该栏底部,再是下一个空栏的顶部和底部(通栏浮动体则是页底,前提是每栏都还有空间);哪里都放不下的,就等到下一页,在那里等待中的浮动体先占据各自的浮动带,然后才排入文本。各栏缩短以容纳在浮动带之间,文本不间断地流过变窄的栏,读者在提到图的位置附近(但不一定恰好在那里)看到它。这是专业排版的标准做法,图书中随处可见。

放置规则。除了放置分派之外,浮动体还遵循严格的编辑约束:

  • 引用在先规则。浮动体落在正文中首次引用它之后的第一个空闲位置,绝不会在引用之前。读者先读到引用,再看到资源。浮动体放不下时,向后延到更晚的位置或页面,绝不往前挪。
  • 同一序列内保持引用顺序。等待中的浮动体按首次引用的顺序依次尝试每个位置;某个浮动体哪里都放不下时,同一编号序列中排在它后面的浮动体也要等待:表3绝不会排在表4之后,图12绝不会排在图11之前。不同序列之间互不阻挡,一个等待中的表不会挡住后面的图。为了让长表不必等到新开一页,一个被分配到空栏顶部的长表会在该栏处截断,在下一个位置(旁边的栏或下一页的浮动带)接续,并重复表头行。
  • 章节屏障。浮动体绝不会越出自己所在的章。在章首页(设置了breakBefore或span: 'page'的标题级别)、:::part、带floatBarrier: true的标注框样式以及文档末尾,所有等待中的浮动体都会先被放下:先放在本页的空闲位置,再放到边界之前新开的页面上(每页至少强制放置一个浮动体),然后才执行边界本身的分页。在打开这样的页面之前,仍在等待的图或表会再尝试一次当前页的空闲位置,不论其position如何:在一章最后一页被引用的页首浮动体会占据该页底部、位于已齐底的各栏下方,而不是单独占一页(浮动的标注框保持自己的放置方式)。:::pagebreak会把等待中的浮动体送到它之后的那一页,排在为奇偶页补齐而插入的页面之后。
  • 最少文本空间。在新开的页面上,只有受影响的栏中仍能容纳至少3行正文时,才会预留浮动带。有一个例外:超大的浮动体可以被强制放进一个仍然全是文本的浮动带,以免一个占主导地位的图永远卡住队列。当前页上的位置必须能放进该栏的剩余高度;3行规则在那里只适用于紧邻另一个浮动带的情况。
  • 留白。浮动带与旁边的文本之间隔开一个正文行高。
  • 基线网格对齐。顶部浮动带向上取整到基线网格的整数倍(增大浮动体下方的间隙),使每一行被推开的文本仍然落在网格上。底部浮动体的锚定方式是让题注的最后一条基线落在网格上:题注与相邻各栏最后一行文本共用基线,各栏和对页的页面在同一高度结束。

按类型、按首次引用编号。资源不在Markdown中编号。pipeline/resourceNumbering.ts按阅读顺序,在每个资源首次被引用时为它分配编号,依据是资源的ResourceType:numberingTemplate把按类型计数的{n}与引用处生效的标题计数器{h1}..{h6}组合起来(例如'{h1}.{n}' → “1.7”),resetOn控制计数器何时重置('never'或任意标题级别),counterFormat选择十进制、罗马数字或字母编号。内置的图和表类型来自defaultResourceTypes(locale),按文档语言本地化。由于编号遵循首次引用的顺序,在文档中间插入一张新图时,其后的所有编号会自动更新,无需修改源文件。

#第5阶段:分页与保持同页规则

排版引擎与只会堆砌文字的工具,区别就在这个阶段。它执行专业排版师几百年来手工遵循的编辑质量规则,而简单的文本渲染对这些规则完全不管。

这项工作由三种机制分担,各自在所需信息存在的地方起作用:断行器内部的孤字惩罚值,段落遇到栏末时的计分拆分,以及块之间的结构性保持同页规则。

段首孤行、段末孤行和孤字

段末孤行和段首孤行是业余排版最明显的标志:

  • 段首孤行(widow)是段落在栏底单独留下的一行。段落在下一栏继续,但这孤零零的一行 看起来被搁浅了(好像这一栏提前结束了)。
  • 段末孤行(orphan)是段落搁浅在栏顶的单独一行。段落的主体在上一栏,只有一行溢了过来 (看起来与上下文脱节)。
  • 孤字是指段落最后一行只有一两个短词,从视觉上看太短,不像一行正常的文字。它在结构上不如段首孤行严重,但对细心的读者同样刺眼。

孤字在断行器中解决。孤字只与段落本身有关:无论段落落在哪里,它的最后一行都一样短。所以三条规则中只有这一条是Knuth-Plass断行器看得到的。如果一组断点会让段落的最后一行内容宽度小于runtMinCharacters × normalSpaceWidth(runtMinCharacters默认20:二十个词间空格宽的内容,大约8到12个字母),断行器就在平方缺陷值公式中把runtPenalty(默认1000)加到这一行的劣度上。这样它与行劣度(在10000处饱和)在同一尺度上竞争,而不会被后者淹没。这个惩罚值与通常的缺陷值(劣度、断词代价、适配等级不匹配)一起参与同一个全局优化,断行器几乎总能找到一组没有孤字的断点。找不到时,只要词间空格加上少量负字距能吸收这一差额,bodyText.tightenRunts就把段落少排一行。测量模块把惩罚值交给断行器(measure/plain.ts、measure/rich.ts),由knuthPlass/breakpoints.ts应用。

段首孤行和段末孤行在栏末解决。断行器按某个宽度给段落断行。它不知道段落会落在页面的什么位置,所以无从知道哪一行会结束一栏。放置遍知道。段落的各行无法全部放进这一栏剩下的空间时,chooseParagraphSplit(位于pipeline/orphanWidow.ts)对已经断好的各行尝试每一种拆分k,从0到放得下的行数:k行留下,其余的行开启下一栏。每个候选拆分这样计分:

  • slackWeight · slack²,对应它在栏底留空的行,其中slack是放得下的行数减去k(slackWeight默认10);
  • 在栏底留下的行少于widowMinLines时,加上widowPenalty(默认1000);
  • 带到下一栏顶部的行少于orphanMinLines时,加上orphanPenalty(默认1000)。

得分最低的拆分胜出,k = 0表示整段后移。这些规则是软约束,软到什么程度可以从数字看出:1000的惩罚值相当于十个空行的余量代价(10 · 10² = 1000),所以在默认值下,只要栏内留空的行少于十行,同时满足两条规则的拆分就会胜出。超过这个限度,段首孤行或段末孤行就可能是代价更低的选择。拆分只在断行器已经断好的行中选择,不重新给段落断行。列表项通过avoidOrphansInLists和avoidWidowsInLists走同样的拆分,并通过avoidRuntsInLists接受孤字惩罚值(默认都为true)。

所有这些都可以在BodyTextConfig中调整,参见配置 → 段首孤行、段末孤行、孤字与保持同页规则。把任何*Penalty设为0,实际上就关闭了对应的规则。

结构性保持同页规则

有些组合比单个段落更大:它们跨越相邻的块,仅靠断行无法处理。放置遍在每个块落定时执行这些规则,当这些组合会被分栏或分页拆开时,把整组向后移:

  • 标题与其第一段。如果标题所引出的段落会从下一栏开始,标题就绝不能出现在栏底。由headings.keepWithNext(默认true)执行:如果放不下标题加上下一个块的正文段首孤行最小行数(bodyText.widowMinLines,默认2;avoidWidows关闭时只算一行),标题就被推到后面,与其正文一起移动。
  • 连续的标题。多个标题相继出现时(例如h2之后是h3,再之后是段落),整组必须保持在一起。任何一个标题都不能被留在栏底而脱离它所引出的内容。
  • 以冒号引出的列表。段落以直接引出列表的冒号结尾时,带冒号的那一行必须与列表的开头保持在一起。由bodyText.keepColonWithList(默认true)执行:如果放下这个段落后没有空间让第一个列表项开始(当列表的段首孤行和段末孤行规则使它不可拆分时,需要容纳整个列表项;设置bodyText.colonListRoom: 'line'时只需一行,与postext 1.4及以前的行为相同),带冒号的最后一行(如果段落只有一行,则是整个段落)就与列表一起后移。每当这条规则需要推动整个段落,而该栏中紧挨在它前面有一串标题时,这些标题也会被一起拉走,以免悄悄违反keepWithNext;唯一的例外是,该栏中只有先前一次移动已经带到后面的标题,此时引擎让段落与标题待在一起,接受冒号与列表分离这一较轻的问题,以避免循环。
  • 图与其题注。图和题注是不可分割的整体,总是一起移动。

检测到违反保持同页规则时,引擎把整组推到下一栏或下一页。它离开的那一栏于是提前结束,短了这一组本该占用的行数。把它填满是各栏齐底的工作。

这些修正做得好时是看不见的,读者不应察觉到它们。但只要细心阅读,一旦缺少这些修正就会立刻发现:栏顶那一行别扭的孤行,引擎放弃让文本排得下时留下的那些不均匀的空隙。专业出版社有整本的体例手册专门防止这些问题。Postext把它们自动化了。

#放置遍的驱动器

放置遍及其重新运行一个放置遍带着内容走过五个阶段:结构化、测量、放置、浮动体,以及分页与保持同页规则。buildDocument带着前一遍的提示把整个放置遍重新运行:栏带上限、齐底手段、收尾栏带的切割、已确定的页码标签或注释编号。一个放置遍第1阶段结构化第2阶段测量第3阶段放置第4阶段浮动体第5阶段分页、保持同页带着提示重新运行:栏带上限、齐底手段、收尾栏带的切割
一遍放置整份文档。buildDocument带着提示重新运行它,直到每一轮稳定下来或达到上限。

buildDocument是围绕放置遍的驱动器(buildDocumentGen和buildDocumentBalanced,位于pipeline/build.ts)。它先把这一遍运行一次。然后读取这一遍报告的情况,哪个决定需要再看一次,它就带着提示(PassHints)把整个放置遍重新运行:提示是下一遍要应用的上限、额外行数和编号。下表中的条件一个都没有出现时,第一遍就是整个构建。

重新运行分成若干轮,每一轮针对一类决定,顺序如下:

轮运行条件告诉下一遍的内容上限
1. 目录文档印出宿主没有提供的页码:内容中没有outline的:::toc或:::index,或者印出某个锚点所在页码的引用带有上一次构建所得页码标签的大纲MAX_TOC_ROUNDS:第一次构建之外最多3次构建
2. 注释编号footnotes.numbering为'page'、'column'或'spread',且注释排在栏底或跨页的底部,因此注释的编号取决于版面把它排在哪里每条注释在上一次构建中得到的编号(footnoteNumbers)MAX_FOOTNOTE_ROUNDS:第一次构建之外最多3次构建
3. 侧栏题注侧栏中的框或浮动体压住了某幅图排在侧栏里的题注(placement.captionSide)这些题注改排在各自的图下方(captionUnder)3遍
4. 引用页上的浮动体带placement.citingPage的浮动体落在了引用它的那一行之后的页或栏该页(或该栏)以这个浮动体开头(citingPageFloats)MAX_CITING_PAGE_ROUNDS:3轮提议;此后,某一遍没能留在那里的浮动体,每一批再运行一遍
5. 栏带上限通栏块到达时,所在栏带的各栏不齐(见第3阶段)该栏带的切割高度(bandCaps)MAX_BAND_PASSES:提议和重试共4遍。一个上限每次增加一行,最多3次(MAX_BAND_CAP_RETRIES),之后被放弃
6. 各栏齐底headings.balancing.enabled开启,且有可齐底的栏提前结束每个块额外增加的网格行,以及要多排一行的段落(balanceExtraPx、balanceLooseness)MAX_BALANCING_PASSES:每个分段10次尝试。MAX_BALANCING_PASSES_PER_DOCUMENT:每份文档30遍
7. 收尾栏带齐底开启且设了headings.balancing.trailing,而某一章或整份文档结束于各栏不齐的栏带该栏带的收尾上限,同时冻结齐底提示MAX_BAND_PASSES:提议和重试共4遍,每个上限各有3次重试
8. 润色第7轮保留了某个收尾上限在带上限的版面上再做一次各栏齐底,分段重新划分每个分段10次尝试、共30遍,重新计数

第3至8轮在一次构建之内依次运行(buildDocumentBalanced)。第1、2轮在它之外:一轮目录或一轮注释编号是一次完整的构建,带有它自己的第3至8轮。整个构建没有总的上限,只有上述这些。

每次重新运行都是一次完整的放置。提示不会去修补前一遍的树。这一遍在提示生效的情况下把文档重新放置一次,所以,上方多了两行的标题,或者在高出六行处切割的栏带,都和其他内容一样经过同样的分页规则和保持同页规则。随后驱动器检查结果:框没有落在切割处的栏带上限会被加大或放弃,没有改善某个分段的齐底遍对该分段作废。驱动器返回的版面由某一遍放置出的页面组成:要么是某一遍的全部页面,要么在各栏齐底之后,每个分段的页面取自该分段最好的那一遍。

文档报告什么。doc.iterationCount统计返回的版面背后的放置遍数:第一遍,加上栏带上限、各栏齐底和收尾栏带各轮的那些遍。侧栏题注和引用页的各遍不计入,目录轮或注释编号轮中较早的构建也不计入;BuildDocumentOptions.onPass在每一遍都会被调用,并带有该遍的实际耗时。doc.converged只与各栏齐底有关。每个分段结束时,可齐底的栏中没有空行,或者已经没有手段可以提议,它就为true;有分段停在没有改善它的一遍上,或者用完了尝试次数,而空隙仍然存在,它就为false。齐底关闭时它为true。converged: false的文档是完整的:它保存着驱动器找到的最好版面,只是其中某一栏提前结束。

#各栏齐底

  • 运行时机:作为驱动器的第6轮和第8轮(默认开启;headings.balancing.enabled: false可将其关闭,在竖排和字符网格上默认关闭,见配置 → 各栏齐底)
  • 输入:目前为止最好的版面,以及每个可齐底的栏底部的空网格行(collectColumnGaps,位于pipeline/columnBalancing.ts)
  • 处理:提议在原处填满每个短栏的手段,带着它们运行放置遍,分段的空隙分数下降时为该分段保留结果
  • 输出:结束于页面最后一个网格行的各栏,每个手段都记录在它所作用的块上(VDTBlock.balancing)

各栏不齐是一眼就能看出来的。分页规则随时都在留下短栏:为了与正文待在一起而后移的标题,为了避免段首孤行而整段后移的段落,一幅放不下的图。比旁边一栏早两行结束的栏看上去像没做完,仿佛排版进行到一半就放弃了。

修正它时没有任何块换栏。分栏的位置与那个放不下的元素绑定在一起:开启下一栏的块对当时剩下的空间来说太高,对更小的空间来说仍然太高。所以短栏可以从内部填满,填充量最多等于它的空隙,没有任何内容越入下一栏。这种调整是局部的,排字工手工做的也正是这件事:在眼睛能接受的地方加一行空。如果内容流过某一栏进入了同页后面的栏,这一栏就是可齐底的;对一页的最后一栏来说,条件是该页的文本流继续到下一页。一章的最后一页可以提前结束;它的收尾栏带改由收尾上限切齐(见第3阶段)。

对每个空隙,手段按编辑上的优先顺序提议,与配置 → 各栏齐底列出的顺序相同:

  1. 收尾的框:结束一栏的框把其底部下方的空余当作它上方的空白,使它的下边缘落在页面最后一个网格行上,与相邻栏的最后一行平齐。记录为trailingCallout。
  2. 有安全区的图片:设了安全区(Resource.safeArea)的图片,如果以行内方式排在这一栏里,或者作为只占这一栏的浮动体排在栏顶或栏底,就按整数个网格行增高,在安全区以内裁切。图片变高不会在页面上留下空洞,所以这一手段在增加任何空白之前执行。记录为flexFigure。
  3. 标题:在标题上方增加整数个网格行,各标题轮流每次加一行,最重要的标题(级别数最小的)排在最前,因此分得最多。位于栏顶的标题从不加空,因此各栏始终从页面顶部开始。一个标题这样只先加最初几行,然后轮到后面的手段;只有它们再也补不了时,标题才继续加行,直到它的上限。记录为heading。
  4. 列表结尾:在列表结束处增加一个网格行。记录为listEnd。
  5. 公式、框和图的下方:在它们下方增加一个网格行:独立公式或标注框之下、文字接续之处(afterDisplay),以及栏首的图或表与其下文字之间(afterFloat)。
  6. 放松段落:最后手段。栏内的段落从最长的开始,各自重新断行、多排一行(即TeX的\looseness=+1,通过Knuth-Plass断行器的looseness选项),只有每一行都保持在bodyText.maxWordSpacing以内时,放松后的方案才会被采用。单靠词间距无法多出这一行时,段落还可以采用恰好能多出这一行的最小正字距,字距有上限。记录为looseParagraph。

各项上限、开关和例外情况(字符网格、收尾各页、跨多栏的浮动体)都是headings.balancing的键。配置页列出了它们,并随它们保持更新;本页不再重复。

提议要靠放置来检验。应用这些手段的那一遍是一次完整的放置,偶尔某个手段终究还是让内容越过了分栏处:头部再也放不下的拆分段落,失去了位置的浮动体。那一点之后的页面排法变了,别处就出现空隙。驱动器从分数上看出这一点,对该分段作废这一遍,并把第一个发生变化的栏中新应用的手段列入黑名单,于是下一次提议保留有效的手段,去掉引起连锁反应的那一个。

各栏齐底按分段收敛。显式断点(章首页、:::pagebreak)之间的页面彼此独立排版:没有内容会跨越这样的断点流动,因此一段页面中的齐底手段不可能移动另一段中的任何一行。所以各栏齐底循环对每个这样的分段单独评判。分段的空隙分数是其可齐底的栏底部空网格行的数目。每一遍仍然放置整份文档,但每个分段按自己的空隙分数决定保留或拒绝自己那部分手段,把自己的连锁反应列入黑名单,自己判断进入平台期,并消耗自己的尝试预算;某个分段重试后变差时,会从它自己最好的那一遍取回页面,其他分段则继续前进。这样,一本三十章的书排出来的结果与逐章单独排版完全相同(全书PDF与同一章的单章PDF完全一致),而不会因为任何地方的一次连锁反应就让全书每一页多跑一遍。

这些上限是安全阀。有些栏无法填满:只有短段落的栏没有可用的手段;每个手段都会把某个块推过分栏处的栏,也没有一个手段留得住。引擎保留它找到的最好版面,让那一栏短着。由于手段记录在块上,校样或测试可以列出没有任何手段能补齐的栏(见配置 → 哪个手段起了作用)。